/* * Copyright (c) Meta Platforms, Inc. and affiliates. * All rights reserved. * * Licensed under the Oculus SDK License Agreement (the "License"); * you may not use the Oculus SDK except in compliance with the License, * which is provided at the time of installation or download, or which * otherwise accompanies this software in either electronic or hard copy form. * * You may obtain a copy of the License at * * https://developer.oculus.com/licenses/oculussdk/ * * Unless required by applicable law or agreed to in writing, the Oculus SDK * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ using System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; using UnityEngine; using UnityEngine.Assertions; [HelpURL("https://developer.oculus.com/reference/unity/latest/class_o_v_r_custom_face_extensions")] public static class OVRCustomFaceExtensions { /// /// Find the best matching blend shape for each facial expression based on their names /// /// /// Auto generation idea is to tokenize expression enum strings and blend shape name strings and find matching tokens /// We quantify the quality of the match by the total number of characters in the matching tokens /// We require at least a total of more than 2 characters to match, to avoid matching just L/R LB/RB etc. /// A better technique might be to use Levenshtein distance to match the tokens to allow some typos while still being loose on order of tokens /// /// The mesh to find a mapping for. /// Array of blend shape names /// Array of FaceExpression id for mapping to them /// Whether to allow duplicate mapping or not /// Returns an array of of the same length as the number of blendshapes on the with each element identifying the closest found match public static OVRFaceExpressions.FaceExpression[] AutoGenerateMapping( Mesh skinnedMesh, string[] blendShapeNames, OVRFaceExpressions.FaceExpression[] faceExpressions, bool allowDuplicateMapping) { Assert.AreEqual(blendShapeNames.Length, faceExpressions.Length); var result = new OVRFaceExpressions.FaceExpression[skinnedMesh.blendShapeCount]; var expressionTokens = new HashSet[blendShapeNames.Length]; for (int i = 0; i < blendShapeNames.Length; ++i) { expressionTokens[i] = TokenizeString(blendShapeNames[i]); } var usedBlendshapes = new HashSet(); for (int i = 0; i < skinnedMesh.blendShapeCount; ++i) { var blendShapeName = skinnedMesh.GetBlendShapeName(i); var bestMatchFound = FindBestMatch( expressionTokens, blendShapeName, faceExpressions, OVRFaceExpressions.FaceExpression.Invalid); // If not allowing duplicates, make an exception for liptowards. if (!allowDuplicateMapping && (usedBlendshapes.Contains(bestMatchFound) && !IsLipsToward(blendShapeName))) { result[i] = OVRFaceExpressions.FaceExpression.Invalid; } else { result[i] = bestMatchFound; usedBlendshapes.Add(bestMatchFound); } } return result; } private static OVRFaceExpressions.FaceExpression FindBestMatch(HashSet[] tokenizedOptions, string searchString, OVRFaceExpressions.FaceExpression[] expressions, OVRFaceExpressions.FaceExpression fallback) { searchString = searchString.Substring(searchString.LastIndexOf('.') + 1); //remove model name prefix if present HashSet blendShapeTokens = TokenizeString(searchString); OVRFaceExpressions.FaceExpression bestMatch = fallback; // require more than two characters to match in an expression, to avoid just matching L/ LB/ R/RB int bestMatchCount = 2; for (int j = 0; j < tokenizedOptions.Length; ++j) { int thisMatchCount = 0; HashSet thisSet = tokenizedOptions[j]; // Currently we only allow exact matches, using Levenshtein distance for fuzzy matches // would allow for handling of common typos and other slight mismatches foreach (string matchingToken in blendShapeTokens.Intersect(thisSet)) { thisMatchCount += matchingToken.Length; } if (thisMatchCount > bestMatchCount) { bestMatchCount = thisMatchCount; bestMatch = expressions[j]; } } return bestMatch; } private static bool IsLipsToward(string blendshapeName) { blendshapeName = blendshapeName.Substring(blendshapeName.IndexOf('.') + 1); return blendshapeName == "lipsToward_LB" || blendshapeName == "lipsToward_RB" || blendshapeName == "lipsToward_LT" || blendshapeName == "lipsToward_RT"; } internal static HashSet TokenizeString(string s) { var separators = new char[] { ' ', '_', '-', ',', '.', ';' }; // add both the camel case and non-camel case split versions since the // camel case split doesn't handle all caps //(it's fundamentally ambigous without natural language comprehension) // duplicates don't matter as we later will hash them and they should match var splitTokens = SplitCamelCase(s).Split(separators).Concat(s.Split(separators)); var hashCodes = new HashSet(); foreach (string token in splitTokens) { string lowerCaseToken = token.ToLowerInvariant(); // give a chance for synonyms to mach with low weight if (lowerCaseToken == "left" || lowerCaseToken == "l") { hashCodes.Add("L"); } if (lowerCaseToken == "right" || lowerCaseToken == "r") { hashCodes.Add("R"); } hashCodes.Add(lowerCaseToken); } return hashCodes; } private static string SplitCamelCase(string input) => System.Text.RegularExpressions.Regex .Replace(input, "([A-Z])", " $1", System.Text.RegularExpressions.RegexOptions.Compiled).Trim(); public static void AutoMapBlendshapes(this OVRCustomFace customFace) { var type = customFace.retargetingType; var renderer = customFace.GetComponent(); try { OVRFaceExpressions.FaceExpression[] generatedMapping; switch (type) { case OVRCustomFace.RetargetingType.OculusFace: generatedMapping = OculusFaceAutoGenerateMapping(renderer.sharedMesh, customFace._allowDuplicateMapping); break; case OVRCustomFace.RetargetingType.Custom: generatedMapping = CustomAutoGeneratedMapping(customFace, renderer.sharedMesh, customFace._allowDuplicateMapping); break; default: throw new InvalidEnumArgumentException($"Invalid {nameof(OVRCustomFace.RetargetingType)}"); } if (generatedMapping != null) { Assert.AreEqual(generatedMapping.Length, renderer.sharedMesh.blendShapeCount); if (customFace._mappings == null || customFace._mappings.Length != renderer.sharedMesh.blendShapeCount) { customFace._mappings = new OVRFaceExpressions.FaceExpression[renderer.sharedMesh.blendShapeCount]; } for (int i = 0; i < renderer.sharedMesh.blendShapeCount; ++i) { customFace._mappings[i] = generatedMapping[i]; } } } catch (Exception e) { Debug.LogError($"Auto Map Face Error: {e.Message}"); } } public static void ClearBlendshapes(this OVRCustomFace customFace) { var renderer = customFace.GetComponent(); for (int i = 0; i < renderer.sharedMesh.blendShapeCount; ++i) { customFace._mappings[i] = OVRFaceExpressions.FaceExpression.Invalid; } } internal static OVRFaceExpressions.FaceExpression[] OculusFaceAutoGenerateMapping(Mesh sharedMesh, bool allowDuplicateMapping) { string[] oculusBlendShapeNames = Enum.GetNames(typeof(OVRFaceExpressions.FaceExpression)); OVRFaceExpressions.FaceExpression[] oculusFaceExpressions = (OVRFaceExpressions.FaceExpression[])Enum.GetValues(typeof(OVRFaceExpressions.FaceExpression)); return AutoGenerateMapping(sharedMesh, oculusBlendShapeNames, oculusFaceExpressions, allowDuplicateMapping); } internal static OVRFaceExpressions.FaceExpression[] CustomAutoGeneratedMapping(OVRCustomFace customFace, Mesh sharedMesh, bool allowDuplicateMapping) { string[] customBlendShapeNames; OVRFaceExpressions.FaceExpression[] customFaceExpressions; (customBlendShapeNames, customFaceExpressions) = customFace.GetCustomBlendShapeNameAndExpressionPairs(); return AutoGenerateMapping(sharedMesh, customBlendShapeNames, customFaceExpressions, allowDuplicateMapping); } }