Improve InferPrefixSuffix / Support using Humanoid Rig on Setup Outfit (#1167)

* feat: inferring prefix/suffix now supports infer with HeuristicBoneMapper

* feat: inferring prefix/suffix is now triggered when prefix/suffix is empty and merge target changed

* chore: add comment for inferring prefix/suffix with HeuristicBoneMapper

* feat: support using Humanoid Rig on RenameBonesByHeuristic

* feat: support using cloth's Humanoid Rig on merge_armature.adjust_names

* feat: support outfits' hips in one more deep place

* chore: refine condition on Heuristic Bone Mapper's exact humanoid bone matching

* chore: unify the process for get outfit's humanoid bones

* chore: rename variable name to clarify means

* chore: use InitializeOnLoadMethod instead of reflection to get boneNamePattern from Editor Assembly

* test: add some tests for SetupOutfit and InferPrefixSuffix
This commit is contained in:
Sayamame-beans 2024-11-03 07:17:24 +09:00 committed by GitHub
parent e752762d21
commit 497d16f89d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 409 additions and 24 deletions

View File

@ -243,6 +243,12 @@ namespace nadena.dev.modular_avatar.core.editor
internal static readonly ImmutableDictionary<string, List<HumanBodyBones>> NameToBoneMap;
internal static readonly ImmutableDictionary<HumanBodyBones, ImmutableList<string>> BoneToNameMap;
[InitializeOnLoadMethod]
private static void InsertboneNamePatternsToRuntime()
{
ModularAvatarMergeArmature.boneNamePatterns = boneNamePatterns;
}
static HeuristicBoneMapper()
{
var pat_end_side = new Regex(@"[_\.]([LR])$");
@ -306,7 +312,9 @@ namespace nadena.dev.modular_avatar.core.editor
GameObject src,
GameObject newParent,
List<Transform> skipped = null,
HashSet<Transform> unassigned = null
HashSet<Transform> unassigned = null,
Animator avatarAnimator = null,
Dictionary<Transform, HumanBodyBones> outfitHumanoidBones = null
)
{
Dictionary<Transform, Transform> mappings = new Dictionary<Transform, Transform>();
@ -355,21 +363,65 @@ namespace nadena.dev.modular_avatar.core.editor
var childName = child.gameObject.name;
var targetObjectName = childName.Substring(config.prefix.Length,
childName.Length - config.prefix.Length - config.suffix.Length);
if (!NameToBoneMap.TryGetValue(
NormalizeName(targetObjectName), out var bodyBones))
List<HumanBodyBones> bodyBones = null;
var isMapped = false;
if (outfitHumanoidBones != null && outfitHumanoidBones.TryGetValue(child, out var outfitHumanoidBone))
{
if (avatarAnimator != null)
{
var avatarBone = avatarAnimator.GetBoneTransform(outfitHumanoidBone);
if (avatarBone != null && unassigned.Contains(avatarBone))
{
mappings[child] = avatarBone;
unassigned.Remove(avatarBone);
lcNameToXform.Remove(NormalizeName(avatarBone.gameObject.name));
isMapped = true;
} else {
bodyBones = new List<HumanBodyBones> { outfitHumanoidBone };
}
} else {
bodyBones = new List<HumanBodyBones>() { outfitHumanoidBone };
}
}
if (!isMapped && bodyBones == null && !NameToBoneMap.TryGetValue(
NormalizeName(targetObjectName), out bodyBones))
{
continue;
}
foreach (var otherName in bodyBones.SelectMany(bone => BoneToNameMap[bone]))
if (!isMapped)
{
if (lcNameToXform.TryGetValue(otherName, out var targetObject))
foreach (var bodyBone in bodyBones)
{
mappings[child] = targetObject;
unassigned.Remove(targetObject);
lcNameToXform.Remove(otherName.ToLowerInvariant());
break;
if (avatarAnimator != null)
{
var avatarBone = avatarAnimator.GetBoneTransform(bodyBone);
if (avatarBone != null && unassigned.Contains(avatarBone))
{
mappings[child] = avatarBone;
unassigned.Remove(avatarBone);
lcNameToXform.Remove(NormalizeName(avatarBone.gameObject.name));
isMapped = true;
break;
}
}
}
}
if (!isMapped)
{
foreach (var otherName in bodyBones.SelectMany(bone => BoneToNameMap[bone]))
{
if (lcNameToXform.TryGetValue(otherName, out var targetObject))
{
mappings[child] = targetObject;
unassigned.Remove(targetObject);
lcNameToXform.Remove(otherName.ToLowerInvariant());
isMapped = true;
break;
}
}
}
@ -388,7 +440,7 @@ namespace nadena.dev.modular_avatar.core.editor
return mappings;
}
internal static void RenameBonesByHeuristic(ModularAvatarMergeArmature config, List<Transform> skipped = null)
internal static void RenameBonesByHeuristic(ModularAvatarMergeArmature config, List<Transform> skipped = null, Dictionary<Transform, HumanBodyBones> outfitHumanoidBones = null, Animator avatarAnimator = null)
{
var target = config.mergeTarget.Get(RuntimeUtil.FindAvatarTransformInParents(config.transform));
if (target == null) return;
@ -399,7 +451,7 @@ namespace nadena.dev.modular_avatar.core.editor
void Traverse(Transform src, Transform dst)
{
var mappings = AssignBoneMappings(config, src.gameObject, dst.gameObject, skipped: skipped);
var mappings = AssignBoneMappings(config, src.gameObject, dst.gameObject, skipped: skipped, outfitHumanoidBones: outfitHumanoidBones, avatarAnimator: avatarAnimator);
foreach (var pair in mappings)
{

View File

@ -100,7 +100,7 @@ namespace nadena.dev.modular_avatar.core.editor
{
serializedObject.ApplyModifiedProperties();
if (target.mergeTargetObject != null && priorMergeTarget == null
if (target.mergeTargetObject != null && priorMergeTarget != target.mergeTargetObject
&& string.IsNullOrEmpty(target.prefix)
&& string.IsNullOrEmpty(target.suffix))
{
@ -115,7 +115,27 @@ namespace nadena.dev.modular_avatar.core.editor
{
if (GUILayout.Button(G("merge_armature.adjust_names")))
{
HeuristicBoneMapper.RenameBonesByHeuristic(target);
var avatarRoot = RuntimeUtil.FindAvatarTransformInParents(target.mergeTarget.Get(target).transform);
var avatarAnimator = avatarRoot != null ? avatarRoot.GetComponent<Animator>() : null;
// Search Outfit Root Animator
var outfitRoot = ((ModularAvatarMergeArmature)serializedObject.targetObject).transform;
Animator outfitAnimator = null;
while (outfitRoot != null)
{
if (outfitRoot == avatarRoot)
{
outfitAnimator = null;
break;
}
outfitAnimator = outfitRoot.GetComponent<Animator>();
if (outfitAnimator != null && outfitAnimator.isHuman) break;
outfitAnimator = null;
outfitRoot = outfitRoot.parent;
}
var outfitHumanoidBones = SetupOutfit.GetOutfitHumanoidBones(outfitRoot, outfitAnimator);
HeuristicBoneMapper.RenameBonesByHeuristic(target, outfitHumanoidBones: outfitHumanoidBones, avatarAnimator: avatarAnimator);
}
}

View File

@ -8,6 +8,7 @@ using UnityEditor;
using UnityEngine;
using Object = UnityEngine.Object;
using static nadena.dev.modular_avatar.core.editor.Localization;
using System;
#endregion
@ -172,8 +173,11 @@ namespace nadena.dev.modular_avatar.core.editor
PrefabUtility.RecordPrefabInstancePropertyModifications(merge);
var outfitAnimator = outfitRoot.GetComponent<Animator>();
var outfitHumanoidBones = GetOutfitHumanoidBones(outfitRoot.transform, outfitAnimator);
var avatarAnimator = avatarRoot.GetComponent<Animator>();
List<Transform> subRoots = new List<Transform>();
HeuristicBoneMapper.RenameBonesByHeuristic(merge, skipped: subRoots);
HeuristicBoneMapper.RenameBonesByHeuristic(merge, skipped: subRoots, outfitHumanoidBones: outfitHumanoidBones, avatarAnimator: avatarAnimator);
// If the outfit has an UpperChest bone but the avatar doesn't, add an additional MergeArmature to
// help with this
@ -218,7 +222,6 @@ namespace nadena.dev.modular_avatar.core.editor
outfitArmature.name += ".1";
// Also make sure to refresh the avatar's animator humanoid bone cache.
var avatarAnimator = avatarRoot.GetComponent<Animator>();
var humanDescription = avatarAnimator.avatar;
avatarAnimator.avatar = null;
// ReSharper disable once Unity.InefficientPropertyAccess
@ -274,6 +277,37 @@ namespace nadena.dev.modular_avatar.core.editor
}
}
internal static Dictionary<Transform, HumanBodyBones> GetOutfitHumanoidBones(Transform outfitRoot, Animator outfitAnimator)
{
if (outfitAnimator != null)
{
var hipsCheck = outfitAnimator.isHuman ? outfitAnimator.GetBoneTransform(HumanBodyBones.Hips) : null;
if (hipsCheck != null && hipsCheck.parent == outfitRoot)
{
// Sometimes broken rigs can have the hips as a direct child of the root, instead of having
// an intermediate Armature object. We do not currently support this kind of rig, and so we'll
// assume the outfit's humanoid rig is broken and move on to heuristic matching.
outfitAnimator = null;
} else if (hipsCheck == null) {
outfitAnimator = null;
}
}
Dictionary<Transform, HumanBodyBones> outfitHumanoidBones = null;
if (outfitAnimator != null)
{
outfitHumanoidBones = new Dictionary<Transform, HumanBodyBones>();
foreach (HumanBodyBones boneIndex in Enum.GetValues(typeof(HumanBodyBones)))
{
var bone = boneIndex != HumanBodyBones.LastBone ? outfitAnimator.GetBoneTransform(boneIndex) : null;
if (bone == null) continue;
outfitHumanoidBones[bone] = boneIndex;
}
}
return outfitHumanoidBones;
}
internal static void FixAPose(GameObject avatarRoot, Transform outfitArmature, bool strictMode = true)
{
var mergeArmature = outfitArmature.GetComponent<ModularAvatarMergeArmature>();
@ -540,6 +574,7 @@ namespace nadena.dev.modular_avatar.core.editor
}
var hipsCandidates = new List<string>();
var hipsExtraCandidateRoots = new List<Transform>();
if (outfitHips == null)
{
@ -548,6 +583,23 @@ namespace nadena.dev.modular_avatar.core.editor
foreach (Transform child in outfitRoot.transform)
{
foreach (Transform tempHip in child)
{
if (tempHip.name.Contains(avatarHips.name))
{
outfitHips = tempHip.gameObject;
// Prefer the first hips we find
break;
}
hipsExtraCandidateRoots.Add(tempHip);
}
if (outfitHips != null) return true; // found an exact match, bail outgit
}
// Sometimes, Hips is in deeper place(like root -> Armature -> Armature 1 -> Hips).
foreach (Transform extraCandidateRoot in hipsExtraCandidateRoots)
{
foreach (Transform tempHip in extraCandidateRoot)
{
if (tempHip.name.Contains(avatarHips.name))
{
@ -561,6 +613,7 @@ namespace nadena.dev.modular_avatar.core.editor
}
hipsCandidates.Add(avatarHips.name);
hipsExtraCandidateRoots = new List<Transform>();
// If that doesn't work out, we'll check for heuristic bone mapper mappings.
foreach (var hbm in HeuristicBoneMapper.BoneToNameMap[HumanBodyBones.Hips])
@ -581,6 +634,25 @@ namespace nadena.dev.modular_avatar.core.editor
{
outfitHips = tempHip.gameObject;
}
hipsExtraCandidateRoots.Add(tempHip);
}
}
}
if (outfitHips == null)
{
// Sometimes, Hips is in deeper place(like root -> Armature -> Armature 1 -> Hips).
foreach (Transform extraCandidateRoot in hipsExtraCandidateRoots)
{
foreach (Transform tempHip in extraCandidateRoot)
{
foreach (var candidate in hipsCandidates)
{
if (HeuristicBoneMapper.NormalizeName(tempHip.name).Contains(candidate))
{
outfitHips = tempHip.gameObject;
}
}
}
}
}

View File

@ -61,6 +61,9 @@ namespace nadena.dev.modular_avatar.core
public bool mangleNames = true;
// Inserted from HeuristicBoneMapper(Editor Assembly) with InitializeOnLoadMethod
// We use raw `boneNamePatterns` instead of `BoneToNameMap` because BoneToNameMap requires matching with normalized bone name, but normalizing makes raw prefix/suffix unavailable.
internal static string[][] boneNamePatterns;
private ArmatureLockController _lockController;
internal Transform MapBone(Transform bone)
@ -216,14 +219,30 @@ namespace nadena.dev.modular_avatar.core
// GameObject we're attached to.
var baseName = hips.name;
var mergeName = transform.GetChild(0).name;
var isInferred = false;
var prefixLength = mergeName.IndexOf(baseName, StringComparison.InvariantCulture);
if (prefixLength < 0) return;
foreach (var hipNameCandidate in boneNamePatterns[(int)HumanBodyBones.Hips])
{
var prefixLength = mergeName.IndexOf(hipNameCandidate, StringComparison.InvariantCultureIgnoreCase);
if (prefixLength < 0) continue;
var suffixLength = mergeName.Length - prefixLength - baseName.Length;
var suffixLength = mergeName.Length - prefixLength - hipNameCandidate.Length;
prefix = mergeName.Substring(0, prefixLength);
suffix = mergeName.Substring(mergeName.Length - suffixLength);
prefix = mergeName.Substring(0, prefixLength);
suffix = mergeName.Substring(mergeName.Length - suffixLength);
isInferred = true;
break;
}
if (!isInferred) { // Also check with old method as fallback
var prefixLength = mergeName.IndexOf(baseName, StringComparison.InvariantCulture);
if (prefixLength < 0) return;
var suffixLength = mergeName.Length - prefixLength - baseName.Length;
prefix = mergeName.Substring(0, prefixLength);
suffix = mergeName.Substring(mergeName.Length - suffixLength);
}
if (prefix == "J_Bip_C_")
{
@ -242,4 +261,4 @@ namespace nadena.dev.modular_avatar.core
if (mergeTarget != null) yield return mergeTarget;
}
}
}
}

View File

@ -4,7 +4,7 @@ using nadena.dev.modular_avatar.core.editor;
using NUnit.Framework;
using UnityEngine;
public class PreferFirstHipsMatch : TestBase
public class HipsMatchTest : TestBase
{
[Test]
public void SetupHeuristicPrefersFirstHipsMatch()
@ -21,7 +21,28 @@ public class PreferFirstHipsMatch : TestBase
var outfit = CreateChild(root, "Outfit");
var outfit_armature = CreateChild(outfit, "Armature");
var outfit_hips = CreateChild(outfit_armature, "Hips");
Assert.IsTrue(SetupOutfit.FindBones(outfit, out var det_av_root, out var det_av_hips, out var det_outfit_hips));
Assert.AreSame(root, det_av_root);
Assert.AreSame(root_hips, det_av_hips);
Assert.AreSame(outfit_hips, det_outfit_hips);
}
[Test]
public void TestOutfitDeepHipsMatch()
{
var root = CreateCommonPrefab("shapell.fbx");
#if MA_VRCSDK3_AVATARS
root.AddComponent<VRC.SDK3.Avatars.Components.VRCAvatarDescriptor>();
#endif
var root_hips = root.GetComponent<Animator>().GetBoneTransform(HumanBodyBones.Hips).gameObject;
root_hips.name = "hip";
var outfit = CreateChild(root, "Outfit");
var outfit_armature = CreateChild(outfit, "armature");
var outfit_armature2 = CreateChild(outfit_armature, "armature2");
var outfit_hips = CreateChild(outfit_armature2, "hips");
Assert.IsTrue(SetupOutfit.FindBones(outfit, out var det_av_root, out var det_av_hips, out var det_outfit_hips));
Assert.AreSame(root, det_av_root);
Assert.AreSame(root_hips, det_av_hips);

View File

@ -0,0 +1,108 @@
using modular_avatar_tests;
using nadena.dev.modular_avatar.core;
using NUnit.Framework;
using UnityEngine;
public class InferPrefixSuffixTest : TestBase
{
[Test]
public void TestNoPrefixSuffix()
{
var root = CreateCommonPrefab("shapell.fbx");
#if MA_VRCSDK3_AVATARS
root.AddComponent<VRC.SDK3.Avatars.Components.VRCAvatarDescriptor>();
#endif
var root_hips = root.GetComponent<Animator>().GetBoneTransform(HumanBodyBones.Hips);
root_hips.name = "hip";
var outfit = CreateChild(root, "Outfit");
var outfit_armature = CreateChild(outfit, "armature");
var outfit_hips = CreateChild(outfit_armature, "hips");
var outfit_mama = outfit_armature.AddComponent<ModularAvatarMergeArmature>();
outfit_mama.mergeTarget = new AvatarObjectReference();
outfit_mama.mergeTarget.referencePath = RuntimeUtil.RelativePath(root, root_hips.parent.gameObject);
outfit_mama.LockMode = ArmatureLockMode.BaseToMerge;
outfit_mama.InferPrefixSuffix();
Assert.AreEqual("", outfit_mama.prefix);
Assert.AreEqual("", outfit_mama.suffix);
}
[Test]
public void TestDifferentHipsName()
{
var root = CreateCommonPrefab("shapell.fbx");
#if MA_VRCSDK3_AVATARS
root.AddComponent<VRC.SDK3.Avatars.Components.VRCAvatarDescriptor>();
#endif
var root_hips = root.GetComponent<Animator>().GetBoneTransform(HumanBodyBones.Hips);
root_hips.name = "hip";
var outfit = CreateChild(root, "Outfit");
var outfit_armature = CreateChild(outfit, "armature");
var outfit_hips = CreateChild(outfit_armature, "pre_Hips.suf");
var outfit_mama = outfit_armature.AddComponent<ModularAvatarMergeArmature>();
outfit_mama.mergeTarget = new AvatarObjectReference();
outfit_mama.mergeTarget.referencePath = RuntimeUtil.RelativePath(root, root_hips.parent.gameObject);
outfit_mama.LockMode = ArmatureLockMode.BaseToMerge;
outfit_mama.InferPrefixSuffix();
Assert.AreEqual("pre_", outfit_mama.prefix);
Assert.AreEqual(".suf", outfit_mama.suffix);
}
[Test]
public void TestSameHipsName_Success()
{
var root = CreateCommonPrefab("shapell.fbx");
#if MA_VRCSDK3_AVATARS
root.AddComponent<VRC.SDK3.Avatars.Components.VRCAvatarDescriptor>();
#endif
var root_hips = root.GetComponent<Animator>().GetBoneTransform(HumanBodyBones.Hips);
root_hips.name = "TEST_HI";
var outfit = CreateChild(root, "Outfit");
var outfit_armature = CreateChild(outfit, "armature");
var outfit_hips = CreateChild(outfit_armature, "pre_TEST_HI2.suf"); // Make it a little bit different name to confirm it matches the current implementation
var outfit_mama = outfit_armature.AddComponent<ModularAvatarMergeArmature>();
outfit_mama.mergeTarget = new AvatarObjectReference();
outfit_mama.mergeTarget.referencePath = RuntimeUtil.RelativePath(root, root_hips.parent.gameObject);
outfit_mama.LockMode = ArmatureLockMode.BaseToMerge;
outfit_mama.InferPrefixSuffix();
Assert.AreEqual("pre_", outfit_mama.prefix);
Assert.AreEqual("2.suf", outfit_mama.suffix);
}
[Test]
public void TestSameHipsName_Fail()
{
var root = CreateCommonPrefab("shapell.fbx");
#if MA_VRCSDK3_AVATARS
root.AddComponent<VRC.SDK3.Avatars.Components.VRCAvatarDescriptor>();
#endif
var root_hips = root.GetComponent<Animator>().GetBoneTransform(HumanBodyBones.Hips);
root_hips.name = "TE_HIPS_ST";
var outfit = CreateChild(root, "Outfit");
var outfit_armature = CreateChild(outfit, "armature");
var outfit_hips = CreateChild(outfit_armature, "pre_TE_HIPS_ST.suf");
var outfit_mama = outfit_armature.AddComponent<ModularAvatarMergeArmature>();
outfit_mama.mergeTarget = new AvatarObjectReference();
outfit_mama.mergeTarget.referencePath = RuntimeUtil.RelativePath(root, root_hips.parent.gameObject);
outfit_mama.LockMode = ArmatureLockMode.BaseToMerge;
outfit_mama.InferPrefixSuffix();
// Current(v1.10.x) InferPrefixSuffix fail to infer prefix/suffix when avatar has unique prefix/suffix and outfit has their name
Assert.AreNotEqual("pre_", outfit_mama.prefix);
Assert.AreNotEqual(".suf", outfit_mama.suffix);
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 426df05704d87424baeb85496181868d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,71 @@
using modular_avatar_tests;
using nadena.dev.modular_avatar.core;
using nadena.dev.modular_avatar.core.editor;
using NUnit.Framework;
using UnityEngine;
public class SetupOutfitRenameTest : TestBase
{
[Test]
public void TestSetupHumanoidOutfit()
{
var root = CreateCommonPrefab("shapell.fbx");
#if MA_VRCSDK3_AVATARS
root.AddComponent<VRC.SDK3.Avatars.Components.VRCAvatarDescriptor>();
#endif
var root_chest = root.GetComponent<Animator>().GetBoneTransform(HumanBodyBones.Chest);
var outfit = CreateCommonPrefab("shapell.fbx");
outfit.transform.SetParent(root.transform);
var outfit_chest = outfit.GetComponent<Animator>().GetBoneTransform(HumanBodyBones.Chest);
outfit_chest.name = "c";
SetupOutfit.SetupOutfitUI(outfit);
Assert.AreEqual(root_chest.name, outfit_chest.name);
}
[Test]
public void TestSetupUpperChestOutfit()
{
var root = CreateCommonPrefab("shapell.fbx");
#if MA_VRCSDK3_AVATARS
root.AddComponent<VRC.SDK3.Avatars.Components.VRCAvatarDescriptor>();
#endif
var root_armature = root.GetComponent<Animator>().GetBoneTransform(HumanBodyBones.Hips).parent.gameObject;
var root_chest = root.GetComponent<Animator>().GetBoneTransform(HumanBodyBones.Chest).gameObject;
var outfit = CreateChild(root, "Outfit");
var outfit_armature = CreateChild(outfit, "armature");
var outfit_hips = CreateChild(outfit_armature, "hips");
var outfit_spine = CreateChild(outfit_hips, "spine");
var outfit_chest = CreateChild(outfit_spine, "chest");
var outfit_upperchest = CreateChild(outfit_chest, "upperchest");
SetupOutfit.SetupOutfitUI(outfit);
Assert.AreSame(root_armature, outfit_armature.GetComponent<ModularAvatarMergeArmature>().mergeTargetObject);
Assert.AreSame(root_chest, outfit_upperchest.GetComponent<ModularAvatarMergeArmature>().mergeTargetObject);
}
[Test]
public void TestSetupSetupedOutfit()
{
var root = CreateCommonPrefab("shapell.fbx");
#if MA_VRCSDK3_AVATARS
root.AddComponent<VRC.SDK3.Avatars.Components.VRCAvatarDescriptor>();
#endif
var root_hips = root.GetComponent<Animator>().GetBoneTransform(HumanBodyBones.Hips);
var root_armature = root.GetComponent<Animator>().GetBoneTransform(HumanBodyBones.Hips).parent.gameObject;
var outfit = CreateChild(root, "Outfit");
var outfit_armature = CreateChild(outfit, "armature");
var outfit_hips = CreateChild(outfit_armature, "HIP");
outfit_armature.AddComponent<ModularAvatarMergeArmature>();
SetupOutfit.SetupOutfitUI(outfit);
Assert.AreEqual(root_armature, outfit_armature.GetComponent<ModularAvatarMergeArmature>().mergeTargetObject);
Assert.AreEqual(root_hips.name, outfit_hips.name);
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 89b8a54f81c4e7244a858b30825de67c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: