From 4240a4f4cfb35b905ad322ef75ceca97fa9b79b6 Mon Sep 17 00:00:00 2001 From: bd_ Date: Sun, 23 Jul 2023 14:22:25 +0900 Subject: [PATCH] feat: add the Replace Object component --- .../EditModeTests/ReplaceObject.meta | 3 + .../ReplaceObject/ReplaceObjectTests.cs | 169 ++++++++++++++ .../ReplaceObject/ReplaceObjectTests.cs.meta | 3 + .../Editor/AvatarProcessor.cs | 1 + .../Editor/PathMappings.cs | 31 +++ .../Editor/ReplaceObjectPass.cs | 219 ++++++++++++++++++ .../Editor/ReplaceObjectPass.cs.meta | 3 + .../Runtime/AvatarObjectReference.cs | 19 ++ .../Runtime/ModularAvatarReplaceObject.cs | 12 + .../ModularAvatarReplaceObject.cs.meta | 3 + 10 files changed, 463 insertions(+) create mode 100644 Assets/_ModularAvatar/EditModeTests/ReplaceObject.meta create mode 100644 Assets/_ModularAvatar/EditModeTests/ReplaceObject/ReplaceObjectTests.cs create mode 100644 Assets/_ModularAvatar/EditModeTests/ReplaceObject/ReplaceObjectTests.cs.meta create mode 100644 Packages/nadena.dev.modular-avatar/Editor/ReplaceObjectPass.cs create mode 100644 Packages/nadena.dev.modular-avatar/Editor/ReplaceObjectPass.cs.meta create mode 100644 Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarReplaceObject.cs create mode 100644 Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarReplaceObject.cs.meta diff --git a/Assets/_ModularAvatar/EditModeTests/ReplaceObject.meta b/Assets/_ModularAvatar/EditModeTests/ReplaceObject.meta new file mode 100644 index 00000000..5f3544d3 --- /dev/null +++ b/Assets/_ModularAvatar/EditModeTests/ReplaceObject.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 4c1526a4a3c8483696aed2fa0c98e3da +timeCreated: 1690621713 \ No newline at end of file diff --git a/Assets/_ModularAvatar/EditModeTests/ReplaceObject/ReplaceObjectTests.cs b/Assets/_ModularAvatar/EditModeTests/ReplaceObject/ReplaceObjectTests.cs new file mode 100644 index 00000000..bec921a5 --- /dev/null +++ b/Assets/_ModularAvatar/EditModeTests/ReplaceObject/ReplaceObjectTests.cs @@ -0,0 +1,169 @@ +using System; +using nadena.dev.modular_avatar.core; +using nadena.dev.modular_avatar.core.editor; +using nadena.dev.modular_avatar.editor.ErrorReporting; +using NUnit.Framework; +using UnityEditor.VersionControl; +using UnityEngine; +using VRC.SDK3.Avatars.Components; + +namespace modular_avatar_tests.ReplaceObject +{ + public class ReplaceObjectTests : TestBase + { + void Process(GameObject root) + { + var avDesc = root.GetComponent(); + new ReplaceObjectPass(new BuildContext(avDesc)).Process(); + } + + [Test] + public void smokeTest() + { + var root = CreateRoot("root"); + var replacee = CreateChild(root, "replacee"); + var replacement = CreateChild(root, "replacement"); + + replacee.AddComponent(); + replacement.AddComponent(); + + var replaceObject = root.AddComponent(); + replaceObject.targetObject.referencePath = RuntimeUtil.AvatarRootPath(replacee); + + Process(root); + + Assert.AreEqual(1, root.transform.childCount); + Assert.AreEqual(root.transform.GetChild(0).gameObject, replacement); + //Assert.AreEqual(replacement.name, "replacee"); + Assert.IsTrue(replacee == null); + + Assert.NotNull(replacement.GetComponent()); + Assert.IsNull(root.GetComponentInChildren()); + } + + public class TestComponent : MonoBehaviour + { + public Transform transformRef; + public GameObject gameObjectRef; + public BoxCollider memberRef; + public BoxCollider secondMemberRef; + public SphereCollider lostRef; + public Transform rootRef; + } + + [Test] + public void rewritesReferences() + { + var root = CreateRoot("root"); + var replacee = CreateChild(root, "replacee"); + var replacement = CreateChild(root, "replacement"); + var reference = CreateChild(root, "reference"); + + var testComponent = reference.AddComponent(); + testComponent.transformRef = replacee.transform; + testComponent.gameObjectRef = replacee; + testComponent.memberRef = replacee.AddComponent(); + testComponent.secondMemberRef = replacee.AddComponent(); + testComponent.lostRef = replacee.AddComponent(); + testComponent.rootRef = root.transform; + + var newBoxCollider1 = replacement.AddComponent(); + var newBoxCollider2 = replacement.AddComponent(); + newBoxCollider1.center = Vector3.up; // just to make it easier to observe in the debugger + newBoxCollider2.center = Vector3.up * 2; + + var replaceObject = replacement.AddComponent(); + replaceObject.targetObject.Set(replacee); + + Process(root); + + Assert.AreEqual(2, root.transform.childCount); + Assert.AreEqual(root.transform.GetChild(0).gameObject, replacement); + Assert.AreEqual(testComponent.transformRef, replacement.transform); + Assert.AreEqual(testComponent.gameObjectRef, replacement); + Assert.AreEqual(testComponent.memberRef, newBoxCollider1); + Assert.AreEqual(testComponent.secondMemberRef, newBoxCollider2); + Assert.AreEqual(testComponent.lostRef, null); + Assert.AreEqual(testComponent.rootRef, root.transform); + } + + // Test: disallow replacing parent of replacee + [Test] + public void disallowReplacingParent() + { + var root = CreateRoot("root"); + var replacee = CreateChild(root, "replacee"); + var replacement = CreateChild(root, "replacement"); + + var replaceObject = replacement.AddComponent(); + replaceObject.targetObject.Set(root); + + BuildReport.Clear(); + Assert.Throws(() => + { + using (BuildReport.CurrentReport.ReportingOnAvatar(root.GetComponent())) + { + Process(root); + } + }); + } + + [Test] + public void abortWhenTargetNull() + { + var root = CreateRoot("root"); + var replacee = CreateChild(root, "replacee"); + var replacement = CreateChild(root, "replacement"); + + var replaceObject = replacement.AddComponent(); + replaceObject.targetObject.Set(null); + + BuildReport.Clear(); + Assert.Throws(() => + { + using (BuildReport.CurrentReport.ReportingOnAvatar(root.GetComponent())) + { + Process(root); + } + }); + } + + // Test: child object handling + [Test] + public void preservesExistingChildObjects() + { + var root = CreateRoot("root"); + var replacee = CreateChild(root, "replacee"); + var replacement = CreateChild(root, "replacement"); + + var child1 = CreateChild(replacee, "child1"); + var child2 = CreateChild(replacement, "child2"); + + var replaceObject = replacement.AddComponent(); + replaceObject.targetObject.Set(replacee); + + Process(root); + + Assert.AreEqual(1, root.transform.childCount); + Assert.AreEqual(replacement.transform, child1.transform.parent); + Assert.AreEqual(replacement.transform, child2.transform.parent); + } + + // Test: PathMappings + [Test] + public void updatesPathMappings() + { + var root = CreateRoot("root"); + var replacee = CreateChild(root, "replacee"); + var replacement = CreateChild(root, "replacement"); + + var replaceObject = replacement.AddComponent(); + replaceObject.targetObject.Set(replacee); + + PathMappings.Init(root); + Process(root); + + Assert.AreEqual("replacement", PathMappings.MapPath("replacee")); + } + } +} \ No newline at end of file diff --git a/Assets/_ModularAvatar/EditModeTests/ReplaceObject/ReplaceObjectTests.cs.meta b/Assets/_ModularAvatar/EditModeTests/ReplaceObject/ReplaceObjectTests.cs.meta new file mode 100644 index 00000000..3b90222b --- /dev/null +++ b/Assets/_ModularAvatar/EditModeTests/ReplaceObject/ReplaceObjectTests.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: bdc6f8e53d1843ec8d22d7f5141aff52 +timeCreated: 1690621719 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs b/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs index f82eddce..ce6e27bc 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs +++ b/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs @@ -207,6 +207,7 @@ namespace nadena.dev.modular_avatar.core.editor new BoneProxyProcessor().OnPreprocessAvatar(avatarGameObject); new VisibleHeadAccessoryProcessor(vrcAvatarDescriptor).Process(context); new MeshSettingsPass(context).OnPreprocessAvatar(); + new ReplaceObjectPass(context).Process(); new RemapAnimationPass(vrcAvatarDescriptor).Process(context.AnimationDatabase); new BlendshapeSyncAnimationProcessor().OnPreprocessAvatar(avatarGameObject, context); PhysboneBlockerPass.Process(avatarGameObject); diff --git a/Packages/nadena.dev.modular-avatar/Editor/PathMappings.cs b/Packages/nadena.dev.modular-avatar/Editor/PathMappings.cs index 9a5dd80f..790d2b07 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/PathMappings.cs +++ b/Packages/nadena.dev.modular-avatar/Editor/PathMappings.cs @@ -114,6 +114,37 @@ namespace nadena.dev.modular_avatar.core.editor } } + /// + /// Marks an object as having been replaced by another object. All references to the old object will be replaced + /// by the new object. References originally to the new object will continue to point to the new object. + /// + /// + /// + public static void ReplaceObject(GameObject old, GameObject newObject) + { + ClearCache(); + + if (_objectToOriginalPaths.TryGetValue(old, out var paths)) + { + if (!_objectToOriginalPaths.TryGetValue(newObject, out var newObjectPaths)) + { + newObjectPaths = new List(); + _objectToOriginalPaths.Add(newObject, newObjectPaths); + } + + newObjectPaths.AddRange(paths); + + _objectToOriginalPaths.Remove(old); + } + + + if (_transformLookthroughObjects.Contains(old)) + { + _transformLookthroughObjects.Remove(old); + _transformLookthroughObjects.Add(newObject); + } + } + private static ImmutableDictionary BuildMapping(ref ImmutableDictionary cache, bool transformLookup) { diff --git a/Packages/nadena.dev.modular-avatar/Editor/ReplaceObjectPass.cs b/Packages/nadena.dev.modular-avatar/Editor/ReplaceObjectPass.cs new file mode 100644 index 00000000..cf1a53d8 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/ReplaceObjectPass.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using nadena.dev.modular_avatar.editor.ErrorReporting; +using UnityEditor; +using UnityEngine; + +namespace nadena.dev.modular_avatar.core.editor +{ + using UnityObject = UnityEngine.Object; + // ReSharper disable once RedundantUsingDirective + using Object = System.Object; + + internal class ReplaceObjectPass + { + private readonly BuildContext _buildContext; + + public ReplaceObjectPass(BuildContext context) + { + _buildContext = context; + } + + struct Reference + { + public UnityObject Source; + public string PropPath; + } + + public void Process() + { + var avatarDescriptor = _buildContext.AvatarDescriptor; + var replacementComponents = + avatarDescriptor.GetComponentsInChildren(true); + + if (replacementComponents.Length == 0) return; + + // Build an index of object references within the avatar that we might need to fix + Dictionary> refIndex = BuildReferenceIndex(); + + Dictionary replacements + = new Dictionary(); + + foreach (var component in replacementComponents) + { + var targetObject = component.targetObject?.Get(_buildContext.AvatarDescriptor); + + if (targetObject == null) + { + BuildReport.LogFatal("replace_object.null_target", new string[0], + component, targetObject); + UnityObject.DestroyImmediate(component.gameObject); + continue; + } + + if (component.transform.GetComponentsInParent().Contains(targetObject.transform)) + { + BuildReport.LogFatal("replace_object.parent_of_target", new string[0], + component, targetObject); + UnityObject.DestroyImmediate(component.gameObject); + continue; + } + + if (replacements.TryGetValue(targetObject, out var existingReplacement)) + { + BuildReport.LogFatal("replace_object.replacing_replacement", new string[0], + component, existingReplacement.Item1); + UnityObject.DestroyImmediate(component); + continue; + } + + replacements[targetObject] = (component, component.gameObject); + } + + // Execute replacement. For now, we reparent children. + // TODO: Handle replacing recursively. + + foreach (var kvp in replacements) + { + var original = kvp.Key; + var replacement = kvp.Value.Item2; + + replacement.transform.SetParent(original.transform.parent, true); + var siblingIndex = original.transform.GetSiblingIndex(); + + // Move children of original parent + foreach (Transform child in original.transform) + { + child.SetParent(replacement.transform, true); + } + + // Update property references + foreach (var refKey in GetIndexedComponents(original)) + { + var (component, index) = refKey; + + if (!refIndex.TryGetValue(component, out var references)) + { + continue; + } + + UnityObject newValue = null; + if (component is GameObject) + { + newValue = replacement; + } + else + { + var replacementCandidates = replacement.GetComponents(component.GetType()); + + if (replacementCandidates.Length > index) + { + newValue = replacementCandidates[index]; + } + } + + foreach (var reference in references) + { + SerializedObject so = new SerializedObject(reference.Source); + SerializedProperty prop = so.FindProperty(reference.PropPath); + prop.objectReferenceValue = newValue; + so.ApplyModifiedPropertiesWithoutUndo(); + } + } + + PathMappings.ReplaceObject(original, replacement); + + // Destroy original + UnityObject.DestroyImmediate(original); + replacement.transform.SetSiblingIndex(siblingIndex); + } + } + + private IEnumerable<(UnityObject, int)> GetIndexedComponents(GameObject original) + { + yield return (original, -1); + + Dictionary componentTypeIndex = new Dictionary(); + foreach (var component in original.GetComponents()) + { + if (!componentTypeIndex.TryGetValue(component.GetType(), out int index)) + { + index = 0; + } + + componentTypeIndex[component.GetType()] = index + 1; + + yield return (component, index); + } + } + + private Dictionary> BuildReferenceIndex() + { + Dictionary> refIndex = new Dictionary>(); + + IndexObject(_buildContext.AvatarDescriptor.gameObject); + + return refIndex; + + void IndexObject(GameObject obj) + { + foreach (Transform child in obj.transform) + { + IndexObject(child.gameObject); + } + + Dictionary componentIndex = new Dictionary(); + foreach (Component c in obj.GetComponents(typeof(Component))) + { + if (c == null) continue; + if (c is Transform) continue; + + if (!componentIndex.TryGetValue(c.GetType(), out int index)) index = 0; + componentIndex[c.GetType()] = index + 1; + + var so = new SerializedObject(c); + var sp = so.GetIterator(); + bool enterChildren = true; + while (sp.Next(enterChildren)) + { + enterChildren = true; + + if (sp.propertyType == SerializedPropertyType.String) enterChildren = false; + if (sp.propertyType != SerializedPropertyType.ObjectReference) continue; + if (sp.objectReferenceValue == null) continue; + + string path = sp.propertyPath; + if (path == "m_GameObject") continue; + + Reference reference; + if (sp.objectReferenceValue is GameObject) + { + // ok + } + else if (sp.objectReferenceValue is Component) + { + // ok + } + else + { + continue; + } + + reference.Source = c; + reference.PropPath = sp.propertyPath; + + var refKey = sp.objectReferenceValue; + if (!refIndex.TryGetValue(refKey, out var refList)) + { + refList = new List(); + refIndex[refKey] = refList; + } + + refList.Add(reference); + } + } + } + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/ReplaceObjectPass.cs.meta b/Packages/nadena.dev.modular-avatar/Editor/ReplaceObjectPass.cs.meta new file mode 100644 index 00000000..58c58881 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/ReplaceObjectPass.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: b119c0a381384ad88e78550d23997aa7 +timeCreated: 1690004198 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/AvatarObjectReference.cs b/Packages/nadena.dev.modular-avatar/Runtime/AvatarObjectReference.cs index 4c5b6e2c..867ec5dc 100644 --- a/Packages/nadena.dev.modular-avatar/Runtime/AvatarObjectReference.cs +++ b/Packages/nadena.dev.modular-avatar/Runtime/AvatarObjectReference.cs @@ -1,5 +1,6 @@ using System; using UnityEngine; +using VRC.SDK3.Avatars.Components; namespace nadena.dev.modular_avatar.core { @@ -41,6 +42,24 @@ namespace nadena.dev.modular_avatar.core return (_cachedReference = avatar.transform.Find(referencePath)?.gameObject); } + public void Set(GameObject target) + { + if (target == null) + { + referencePath = ""; + } + else if (target.GetComponent() != null) + { + referencePath = AVATAR_ROOT; + } + else + { + referencePath = RuntimeUtil.AvatarRootPath(target); + } + + _cacheValid = false; + } + private void InvalidateCache() { RuntimeUtil.OnHierarchyChanged -= InvalidateCache; diff --git a/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarReplaceObject.cs b/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarReplaceObject.cs new file mode 100644 index 00000000..f441abac --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarReplaceObject.cs @@ -0,0 +1,12 @@ +using UnityEngine; + +namespace nadena.dev.modular_avatar.core +{ + [AddComponentMenu("Modular Avatar/MA Replace Object")] + [DisallowMultipleComponent] + public class ModularAvatarReplaceObject : AvatarTagComponent + { + public AvatarObjectReference targetObject = new AvatarObjectReference(); + // public bool deleteChildren; + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarReplaceObject.cs.meta b/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarReplaceObject.cs.meta new file mode 100644 index 00000000..71ecdd86 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarReplaceObject.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 7e949680c0864ee7b441d9b2c93b890b +timeCreated: 1690004129 \ No newline at end of file