From 28b4527632753eeed046751651fa3f2d16b74e64 Mon Sep 17 00:00:00 2001 From: David Back Date: Fri, 14 Dec 2018 12:56:39 -0800 Subject: [PATCH] Avatar Exporter - joint rotation offsets and file workflow changes --- .../Assets/Editor/AvatarExporter.cs | 358 ++++++++++++++---- .../avatarExporter.unitypackage | Bin 2830 -> 5879 bytes 2 files changed, 279 insertions(+), 79 deletions(-) diff --git a/tools/unity-avatar-exporter/Assets/Editor/AvatarExporter.cs b/tools/unity-avatar-exporter/Assets/Editor/AvatarExporter.cs index 60b5e0e643..3a3cd77496 100644 --- a/tools/unity-avatar-exporter/Assets/Editor/AvatarExporter.cs +++ b/tools/unity-avatar-exporter/Assets/Editor/AvatarExporter.cs @@ -12,8 +12,8 @@ using System; using System.IO; using System.Collections.Generic; -public class AvatarExporter : MonoBehaviour { - public static Dictionary UNITY_TO_HIFI_JOINT_NAME = new Dictionary { +public class AvatarExporter : MonoBehaviour { + public static Dictionary HUMANOID_TO_HIFI_JOINT_NAME = new Dictionary { {"Chest", "Spine1"}, {"Head", "Head"}, {"Hips", "Hips"}, @@ -70,49 +70,97 @@ public class AvatarExporter : MonoBehaviour { {"UpperChest", "Spine2"}, }; - public static string exportedPath = String.Empty; + public static Dictionary referenceAbsoluteRotations = new Dictionary { + {"Head", new Quaternion(-2.509889e-9f, -3.379446e-12f, 2.306033e-13f, 1f)}, + {"Hips", new Quaternion(-3.043941e-10f, -1.573706e-7f, 5.112975e-6f, 1f)}, + {"LeftHandIndex3", new Quaternion(-0.5086057f, 0.4908088f, -0.4912299f, -0.5090388f)}, + {"LeftHandIndex2", new Quaternion(-0.4934928f, 0.5062312f, -0.5064303f, -0.4936835f)}, + {"LeftHandIndex1", new Quaternion(-0.4986293f, 0.5017503f, -0.5013659f, -0.4982448f)}, + {"LeftHandPinky3", new Quaternion(-0.490056f, 0.5143053f, -0.5095307f, -0.4855038f)}, + {"LeftHandPinky2", new Quaternion(-0.5083722f, 0.4954255f, -0.4915887f, -0.5044324f)}, + {"LeftHandPinky1", new Quaternion(-0.5062528f, 0.497324f, -0.4937346f, -0.5025966f)}, + {"LeftHandMiddle3", new Quaternion(-0.4871885f, 0.5123404f, -0.5125002f, -0.4873383f)}, + {"LeftHandMiddle2", new Quaternion(-0.5171652f, 0.4827828f, -0.4822642f, -0.5166069f)}, + {"LeftHandMiddle1", new Quaternion(-0.4955998f, 0.5041052f, -0.5043675f, -0.4958555f)}, + {"LeftHandRing3", new Quaternion(-0.4936301f, 0.5097645f, -0.5061787f, -0.4901562f)}, + {"LeftHandRing2", new Quaternion(-0.5089865f, 0.4943658f, -0.4909532f, -0.5054707f)}, + {"LeftHandRing1", new Quaternion(-0.5020972f, 0.5005084f, -0.4979034f, -0.4994819f)}, + {"LeftHandThumb3", new Quaternion(-0.7228092f, 0.2988393f, -0.4472938f, -0.4337862f)}, + {"LeftHandThumb2", new Quaternion(-0.7554525f, 0.2018595f, -0.3871402f, -0.4885356f)}, + {"LeftHandThumb1", new Quaternion(-0.7276843f, 0.2878546f, -0.439926f, -0.4405459f)}, + {"LeftEye", new Quaternion(-2.509889e-9f, -3.379446e-12f, 2.306033e-13f, 1f)}, + {"LeftFoot", new Quaternion(0.009215056f, 0.3612514f, 0.9323555f, -0.01121602f)}, + {"LeftHand", new Quaternion(-0.4797408f, 0.5195366f, -0.5279632f, -0.4703038f)}, + {"LeftForeArm", new Quaternion(-0.4594738f, 0.4594729f, -0.5374805f, -0.5374788f)}, + {"LeftLeg", new Quaternion(-0.0005380471f, -0.03154583f, 0.9994993f, 0.002378627f)}, + {"LeftShoulder", new Quaternion(-0.3840606f, 0.525857f, -0.5957767f, -0.47013f)}, + {"LeftToeBase", new Quaternion(-0.0002536641f, 0.7113448f, 0.7027079f, -0.01379319f)}, + {"LeftArm", new Quaternion(-0.4591927f, 0.4591916f, -0.5377204f, -0.5377193f)}, + {"LeftUpLeg", new Quaternion(-0.0006682819f, 0.0006864658f, 0.9999968f, -0.002333928f)}, + {"Neck", new Quaternion(-2.509889e-9f, -3.379446e-12f, 2.306033e-13f, 1f)}, + {"RightHandIndex3", new Quaternion(0.5083892f, 0.4911618f, -0.4914584f, 0.5086939f)}, + {"RightHandIndex2", new Quaternion(0.4931984f, 0.5065879f, -0.5067145f, 0.4933202f)}, + {"RightHandIndex1", new Quaternion(0.4991491f, 0.5012957f, -0.5008481f, 0.4987026f)}, + {"RightHandPinky3", new Quaternion(0.4890696f, 0.5154139f, -0.5104482f, 0.4843578f)}, + {"RightHandPinky2", new Quaternion(0.5084175f, 0.495413f, -0.4915423f, 0.5044444f)}, + {"RightHandPinky1", new Quaternion(0.5069782f, 0.4965974f, -0.4930001f, 0.5033045f)}, + {"RightHandMiddle3", new Quaternion(0.4867662f, 0.5129694f, -0.5128888f, 0.4866894f)}, + {"RightHandMiddle2", new Quaternion(0.5167004f, 0.4833596f, -0.4827653f, 0.5160643f)}, + {"RightHandMiddle1", new Quaternion(0.4965845f, 0.5031784f, -0.5033959f, 0.4967981f)}, + {"RightHandRing3", new Quaternion(0.4933217f, 0.5102056f, -0.5064691f, 0.4897075f)}, + {"RightHandRing2", new Quaternion(0.5085972f, 0.494844f, -0.4913519f, 0.505007f)}, + {"RightHandRing1", new Quaternion(0.502959f, 0.4996676f, -0.4970418f, 0.5003144f)}, + {"RightHandThumb3", new Quaternion(0.7221864f, 0.3001843f, -0.4482129f, 0.4329457f)}, + {"RightHandThumb2", new Quaternion(0.755621f, 0.20102f, -0.386691f, 0.4889769f)}, + {"RightHandThumb1", new Quaternion(0.7277303f, 0.2876409f, -0.4398623f, 0.4406733f)}, + {"RightEye", new Quaternion(-2.509889e-9f, -3.379446e-12f, 2.306033e-13f, 1f)}, + {"RightFoot", new Quaternion(-0.009482829f, 0.3612484f, 0.9323512f, 0.01144584f)}, + {"RightHand", new Quaternion(0.4797273f, 0.5195542f, -0.5279628f, 0.4702987f)}, + {"RightForeArm", new Quaternion(0.4594217f, 0.4594215f, -0.5375242f, 0.5375237f)}, + {"RightLeg", new Quaternion(0.0005446263f, -0.03177159f, 0.9994922f, -0.002395923f)}, + {"RightShoulder", new Quaternion(0.3841222f, 0.5257177f, -0.5957286f, 0.4702966f)}, + {"RightToeBase", new Quaternion(0.0001034f, 0.7113398f, 0.7027067f, 0.01411251f)}, + {"RightArm", new Quaternion(0.4591419f, 0.4591423f, -0.537763f, 0.5377624f)}, + {"RightUpLeg", new Quaternion(0.0006750703f, 0.0008973633f, 0.9999966f, 0.002352045f)}, + {"Spine", new Quaternion(-0.05427956f, 1.508558e-7f, -2.775203e-6f, 0.9985258f)}, + {"Spine1", new Quaternion(-0.0824653f, 1.25274e-7f, -6.75759e-6f, 0.996594f)}, + {"Spine2", new Quaternion(-0.0824653f, 1.25274e-7f, -6.75759e-6f, 0.996594f)}, + }; + + public static Dictionary userBoneToHumanoidMappings = new Dictionary(); + public static Dictionary userParentNames = new Dictionary(); + public static Dictionary userAbsoluteRotations = new Dictionary(); [MenuItem("High Fidelity/Export New Avatar")] public static void ExportNewAvatar() { ExportSelectedAvatar(false); } - - [MenuItem("High Fidelity/Export New Avatar", true)] - private static bool ExportNewAvatarValidator() { - // only enable Export New Avatar option if we have an asset selected - string[] guids = Selection.assetGUIDs; - return guids.Length > 0; - } - + [MenuItem("High Fidelity/Update Avatar")] public static void UpdateAvatar() { ExportSelectedAvatar(true); } - [MenuItem("High Fidelity/Update Avatar", true)] - private static bool UpdateAvatarValidation() { - // only enable Update Avatar option if the selected avatar is the last one that was exported - if (exportedPath != String.Empty) { - string[] guids = Selection.assetGUIDs; - if (guids.Length > 0) { - string selectedAssetPath = AssetDatabase.GUIDToAssetPath(guids[0]); - string selectedAsset = Path.GetFileNameWithoutExtension(selectedAssetPath); - string exportedAsset = Path.GetFileNameWithoutExtension(exportedPath); - return exportedAsset == selectedAsset; + public static void ExportSelectedAvatar(bool updateAvatar) { + string[] guids = Selection.assetGUIDs; + if (guids.Length != 1) { + if (guids.Length == 0) { + EditorUtility.DisplayDialog("Error", "Please select an asset to export", "Ok"); + } else { + EditorUtility.DisplayDialog("Error", "Please select a single asset to export", "Ok"); } - } - return false; - } - - public static void ExportSelectedAvatar(bool usePreviousPath) { - string assetPath = AssetDatabase.GUIDToAssetPath(Selection.assetGUIDs[0]); - if (assetPath.LastIndexOf(".fbx") == -1) { - EditorUtility.DisplayDialog("Error", "Please select an fbx avatar to export", "Ok"); return; } + string assetPath = AssetDatabase.GUIDToAssetPath(Selection.assetGUIDs[0]); + string assetName = Path.GetFileNameWithoutExtension(assetPath); + string assetDirectory = Path.GetDirectoryName(assetPath); + if (assetDirectory != "Assets/Resources") { + EditorUtility.DisplayDialog("Error", "Please place asset in the Assets/Resources folder", "Ok"); + return; + } ModelImporter importer = ModelImporter.GetAtPath(assetPath) as ModelImporter; - if (importer == null) { - EditorUtility.DisplayDialog("Error", "Please select a model", "Ok"); + if (assetPath.LastIndexOf(".fbx") == -1 || importer == null) { + EditorUtility.DisplayDialog("Error", "Please select an fbx model asset to export", "Ok"); return; } if (importer.animationType != ModelImporterAnimationType.Human) { @@ -120,88 +168,240 @@ public class AvatarExporter : MonoBehaviour { return; } + userBoneToHumanoidMappings.Clear(); + userParentNames.Clear(); + userAbsoluteRotations.Clear(); + + // instantiate a game object of the user avatar to save out bone parents then destroy it + UnityEngine.Object avatarResource = Resources.Load(assetName); + if (avatarResource) { + GameObject assetGameObject = (GameObject)Instantiate(avatarResource); + SetParentNames(assetGameObject.transform, userParentNames); + DestroyImmediate(assetGameObject); + } + // store joint mappings only for joints that exist in hifi and verify missing joints HumanDescription humanDescription = importer.humanDescription; HumanBone[] boneMap = humanDescription.human; - Dictionary jointMappings = new Dictionary(); + string chestUserBone = ""; + string neckUserBone = ""; foreach (HumanBone bone in boneMap) { - string humanBone = bone.humanName; + string humanName = bone.humanName; + string boneName = bone.boneName; string hifiJointName; - if (UNITY_TO_HIFI_JOINT_NAME.TryGetValue(humanBone, out hifiJointName)) { - jointMappings.Add(hifiJointName, bone.boneName); + if (HUMANOID_TO_HIFI_JOINT_NAME.TryGetValue(humanName, out hifiJointName)) { + userBoneToHumanoidMappings.Add(boneName, humanName); + if (humanName == "Chest") { + chestUserBone = boneName; + } else if (humanName == "Neck") { + neckUserBone = boneName; + } } + } - if (!jointMappings.ContainsKey("Hips")) { + if (!userBoneToHumanoidMappings.ContainsValue("Hips")) { EditorUtility.DisplayDialog("Error", "There is no Hips bone in selected avatar", "Ok"); return; } - if (!jointMappings.ContainsKey("Spine")) { + if (!userBoneToHumanoidMappings.ContainsValue("Spine")) { EditorUtility.DisplayDialog("Error", "There is no Spine bone in selected avatar", "Ok"); return; } - if (!jointMappings.ContainsKey("Spine1")) { - EditorUtility.DisplayDialog("Error", "There is no Chest bone in selected avatar", "Ok"); - return; - } - if (!jointMappings.ContainsKey("Spine2")) { - // if there is no UpperChest (Spine2) bone then we remap Chest (Spine1) to Spine2 in hifi and skip Spine1 - jointMappings["Spine2"] = jointMappings["Spine1"]; - jointMappings.Remove("Spine1"); - } - - // open folder explorer defaulting to user documents folder to select target path if exporting new avatar, - // otherwise use previously exported path if updating avatar - string directoryPath; - string assetName = Path.GetFileNameWithoutExtension(assetPath); - if (!usePreviousPath || exportedPath == String.Empty) { - string documentsFolder = System.Environment.GetFolderPath(System.Environment.SpecialFolder.MyDocuments); - if (!SelectExportFolder(assetName, documentsFolder, out directoryPath)) { + if (!userBoneToHumanoidMappings.ContainsValue("Chest")) { + // check to see if there is a child of Spine that could be mapped to Chest + string spineChild = ""; + foreach (var parentRelation in userParentNames) { + string humanName; + if (userBoneToHumanoidMappings.TryGetValue(parentRelation.Value, out humanName) && humanName == "Spine") { + if (spineChild == "") { + spineChild = parentRelation.Key; + } else { + // found more than one Spine child so we can't choose one to remap + spineChild = ""; + break; + } + } + } + if (spineChild != "" && !userBoneToHumanoidMappings.ContainsKey(spineChild)) { + // use child of Spine as Chest + userBoneToHumanoidMappings.Add(spineChild, "Chest"); + chestUserBone = spineChild; + } else { + EditorUtility.DisplayDialog("Error", "There is no Chest bone in selected avatar", "Ok"); return; } - } else { - directoryPath = Path.GetDirectoryName(exportedPath) + "/"; } - Directory.CreateDirectory(directoryPath); + if (!userBoneToHumanoidMappings.ContainsValue("UpperChest")) { + //if parent of Neck is not Chest then map the parent to UpperChest + if (neckUserBone != "") { + string neckParentUserBone, neckParentHuman; + userParentNames.TryGetValue(neckUserBone, out neckParentUserBone); + userBoneToHumanoidMappings.TryGetValue(neckParentUserBone, out neckParentHuman); + if (neckParentHuman != "Chest" && !userBoneToHumanoidMappings.ContainsKey(neckParentUserBone)) { + userBoneToHumanoidMappings.Add(neckParentUserBone, "UpperChest"); + } + } + // if there is still no UpperChest bone but there is a Chest bone then we remap Chest to UpperChest + if (!userBoneToHumanoidMappings.ContainsValue("UpperChest") && chestUserBone != "") { + userBoneToHumanoidMappings[chestUserBone] = "UpperChest"; + } + } + + bool copyModelToExport = false; + string exportFstPath, exportModelPath; + string documentsFolder = System.Environment.GetFolderPath(System.Environment.SpecialFolder.MyDocuments); + if (updateAvatar) { + // open file explorer defaulting to user documents folder to select target fst to update + exportFstPath = EditorUtility.OpenFilePanel("Select fst to update", documentsFolder, "fst"); + if (exportFstPath.Length == 0) { // file selection cancelled + return; + } + exportModelPath = Path.GetDirectoryName(exportFstPath) + "/" + assetName + ".fbx"; + + if (File.Exists(exportModelPath)) { + // if the fbx in Unity Assets/Resources is newer than the fbx in the + // target export folder or vice-versa then ask to copy fbx over + DateTime assetModelWriteTime = File.GetLastWriteTime(assetPath); + DateTime targetModelWriteTime = File.GetLastWriteTime(exportModelPath); + if (assetModelWriteTime > targetModelWriteTime) { + copyModelToExport = EditorUtility.DisplayDialog("Error", "The " + assetName + + ".fbx model in the Unity Assets/Resources folder is newer than the " + exportModelPath + + " model. Do you want to copy the newer .fbx model over?" , "Yes", "No"); + } else if (assetModelWriteTime < targetModelWriteTime) { + bool copyModelToUnity = EditorUtility.DisplayDialog("Error", "The " + exportModelPath + + " model is newer than the " + assetName + + ".fbx model in the Unity Assets/Resources folder. Do you want to copy the newer .fbx model over?", + "Yes", "No"); + if (copyModelToUnity) { + File.Delete(assetPath); + File.Copy(exportModelPath, assetPath); + } + } + } else { + // if no matching fbx exists in the target export folder then ask to copy fbx over + copyModelToExport = EditorUtility.DisplayDialog("Error", "There is no existing " + exportModelPath + + " model. Do you want to copy over the " + assetName + + ".fbx model from the Unity Assets/Resources folder?" , "Yes", "No"); + } + } else { + // open folder explorer defaulting to user documents folder to select target folder to export fst and fbx to + if (!SelectExportFolder(assetName, documentsFolder, out exportFstPath, out exportModelPath)) { + return; + } + copyModelToExport = true; + } - // delete any existing fst since we agreed to overwrite it - string fstPath = directoryPath + assetName + ".fst"; - if (File.Exists(fstPath)) { - File.Delete(fstPath); + // delete any existing fbx since we would have agreed to overwrite it, and copy asset fbx over + if (copyModelToExport) { + if (File.Exists(exportModelPath)) { + File.Delete(exportModelPath); + } + File.Copy(assetPath, exportModelPath); + } + + // delete any existing fst since we agreed to overwrite it or are updating it + // TODO: should updating fst only rewrite joint mappings and joint rotation offsets? + if (File.Exists(exportFstPath)) { + File.Delete(exportFstPath); } // write out core fields to top of fst file - File.WriteAllText(fstPath, "name = " + assetName + "\ntype = body+head\nscale = 1\nfilename = " + + File.WriteAllText(exportFstPath, "name = " + assetName + "\ntype = body+head\nscale = 1\nfilename = " + assetName + ".fbx\n" + "texdir = textures\n"); // write out joint mappings to fst file - foreach (var jointMapping in jointMappings) { - File.AppendAllText(fstPath, "jointMap = " + jointMapping.Key + " = " + jointMapping.Value + "\n"); + foreach (var jointMapping in userBoneToHumanoidMappings) { + string hifiJointName = HUMANOID_TO_HIFI_JOINT_NAME[jointMapping.Value]; + File.AppendAllText(exportFstPath, "jointMap = " + hifiJointName + " = " + jointMapping.Key + "\n"); } - - // delete any existing fbx since we agreed to overwrite it, and copy fbx over - string targetAssetPath = directoryPath + assetName + ".fbx"; - if (File.Exists(targetAssetPath)) { - File.Delete(targetAssetPath); - } - File.Copy(assetPath, targetAssetPath); - exportedPath = targetAssetPath; + SkeletonBone[] skeletonMap = humanDescription.skeleton; + foreach (SkeletonBone userBone in skeletonMap) { + string userBoneName = userBone.name; + Quaternion userBoneRotation = userBone.rotation; + + string parentName; + userParentNames.TryGetValue(userBoneName, out parentName); + if (parentName == "root") { + // if the parent is root then use bone's rotation + userAbsoluteRotations.Add(userBoneName, userBoneRotation); + } else { + // otherwise multiply bone's rotation by parent bone's absolute rotation + userAbsoluteRotations.Add(userBoneName, userAbsoluteRotations[parentName] * userBoneRotation); + } + + // generate joint rotation offsets for both humanoid-mapped bones as well as extra unmapped bones in user avatar + Quaternion jointOffset = new Quaternion(); + string humanName, outputJointName = ""; + if (userBoneToHumanoidMappings.TryGetValue(userBoneName, out humanName)) { + outputJointName = HUMANOID_TO_HIFI_JOINT_NAME[humanName]; + Quaternion rotation = referenceAbsoluteRotations[outputJointName]; + jointOffset = Quaternion.Inverse(userAbsoluteRotations[userBoneName]) * rotation; + } else if (userAbsoluteRotations.ContainsKey(userBoneName)) { + string lastRequiredParent = FindLastRequiredParentBone(userBoneName); + if (lastRequiredParent != "root") { + // take the previous offset and multiply it by the current local when we have an extra joint + outputJointName = userBoneName; + string lastRequiredParentHifiName = HUMANOID_TO_HIFI_JOINT_NAME[userBoneToHumanoidMappings[lastRequiredParent]]; + Quaternion lastRequiredParentRotation = referenceAbsoluteRotations[lastRequiredParentHifiName]; + jointOffset = Quaternion.Inverse(userAbsoluteRotations[userBoneName]) * lastRequiredParentRotation; + } + } + + // swap from left-handed (Unity) to right-handed (HiFi) coordinate system and write out joint rotation offset to fst + if (outputJointName != "") { + jointOffset = new Quaternion(-jointOffset.x, jointOffset.y, jointOffset.z, -jointOffset.w); + File.AppendAllText(exportFstPath, "jointRotationOffset = " + outputJointName + " = (" + jointOffset.x + ", " + + jointOffset.y + ", " + jointOffset.z + ", " + jointOffset.w + ")\n"); + } + } } - public static bool SelectExportFolder(string assetName, string initialPath, out string directoryPath) { + public static bool SelectExportFolder(string assetName, string initialPath, out string fstPath, out string modelPath) { string selectedPath = EditorUtility.OpenFolderPanel("Select export location", initialPath, ""); if (selectedPath.Length == 0) { // folder selection cancelled - directoryPath = ""; + fstPath = ""; + modelPath = ""; return false; } - directoryPath = selectedPath + "/" + assetName + "/"; - if (Directory.Exists(directoryPath)) { - bool overwrite = EditorUtility.DisplayDialog("Error", "Directory " + assetName + - " already exists here, would you like to overwrite it?", "Yes", "No"); + fstPath = selectedPath + "/" + assetName + ".fst"; + modelPath = selectedPath + "/" + assetName + ".fbx"; + bool fstExists = File.Exists(fstPath); + bool modelExists = File.Exists(modelPath); + if (fstExists || modelExists) { + string overwriteMessage; + if (fstExists && modelExists) { + overwriteMessage = assetName + ".fst and " + assetName + + ".fbx already exist here, would you like to overwrite them?"; + } else if (fstExists) { + overwriteMessage = assetName + ".fst already exists here, would you like to overwrite it?"; + } else { + overwriteMessage = assetName + ".fbx already exists here, would you like to overwrite it?"; + } + bool overwrite = EditorUtility.DisplayDialog("Error", overwriteMessage, "Yes", "No"); if (!overwrite) { - SelectExportFolder(assetName, selectedPath, out directoryPath); + return SelectExportFolder(assetName, selectedPath, out fstPath, out modelPath); } } return true; } + + public static void SetParentNames(Transform modelBone, Dictionary parentNames) { + for (int i = 0; i < modelBone.childCount; i++) { + SetParentNames(modelBone.GetChild(i), parentNames); + } + if (modelBone.parent != null) { + parentNames.Add(modelBone.name, modelBone.parent.name); + } else { + parentNames.Add(modelBone.name, "root"); + } + } + + public static string FindLastRequiredParentBone(string currentBone) { + string result = currentBone; + while (result != "root" && !userBoneToHumanoidMappings.ContainsKey(result)) { + result = userParentNames[result]; + } + return result; + } } diff --git a/tools/unity-avatar-exporter/avatarExporter.unitypackage b/tools/unity-avatar-exporter/avatarExporter.unitypackage index bb25cb4072569356c49f6eaf24d334a65827ec0e..15935b3229ba23614aa7c9f9bf556a304d574369 100644 GIT binary patch literal 5879 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