diff --git a/CHANGELOG-PRERELEASE.md b/CHANGELOG-PRERELEASE.md index cacdb22b..59ae6aaa 100644 --- a/CHANGELOG-PRERELEASE.md +++ b/CHANGELOG-PRERELEASE.md @@ -19,6 +19,10 @@ The format is based on [Keep a Changelog]. - Prefab overrides on the scene are reverted on first load of the scene at first launch `#1372` - Animating transform with C# named properties are broken by merge bone `#1373` - Animator window won't create such animation but some script generates and it works surprisingly +- Errors with blendShapes with exactly same name in a mesh `#1374` + - Such mesh can be generated with Autodesk Maya or 3ds Max + - Unity API denies generating such mesh with C# so AAO will rename such blendShapes to unique name to support. + - Unity Animator does animate first blendshale only so second shape would generally removed by remove unused blendShapes. ### Security diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f183b4..b5a150e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ The format is based on [Keep a Changelog]. - Prefab overrides on the scene are reverted on first load of the scene at first launch `#1372` - Animating transform with C# named properties are broken by merge bone `#1373` - Animator window won't create such animation but some script generates and it works surprisingly +- Errors with blendShapes with exactly same name in a mesh `#1374` + - Such mesh can be generated with Autodesk Maya or 3ds Max + - Unity API denies generating such mesh with C# so AAO will rename such blendShapes to unique name to support. + - Unity Animator does animate first blendshale only so second shape would generally removed by remove unused blendShapes. ### Security diff --git a/Internal/MeshInfo2/MeshInfo2.cs b/Internal/MeshInfo2/MeshInfo2.cs index a9ba9d39..0b0f3bab 100644 --- a/Internal/MeshInfo2/MeshInfo2.cs +++ b/Internal/MeshInfo2/MeshInfo2.cs @@ -14,6 +14,7 @@ using UnityEngine.Rendering; using Debug = System.Diagnostics.Debug; using Object = UnityEngine.Object; +using Random = UnityEngine.Random; namespace Anatawa12.AvatarOptimizer.Processors.SkinnedMeshes { @@ -201,14 +202,9 @@ private void ReadBones(Mesh mesh) private void ReadBlendShapes(Mesh mesh) { - BlendShapes.Clear(); - Profiler.BeginSample("Save Applied Weights"); - for (var blendShape = 0; blendShape < mesh.blendShapeCount; blendShape++) - BlendShapes.Add((mesh.GetBlendShapeName(blendShape), 0.0f)); - Profiler.EndSample(); - Profiler.BeginSample("New Reading Method"); - var buffer = new BlendShapeBuffer(mesh); + BlendShapes.Clear(); + var buffer = new BlendShapeBuffer(mesh, BlendShapes); for (var vertex = 0; vertex < Vertices.Count; vertex++) { Vertices[vertex].BlendShapeBuffer = buffer; @@ -1203,7 +1199,7 @@ public class BlendShapeBuffer : IReferenceCount public readonly NativeArray[] DeltaTangents; public readonly int VertexCount; - public BlendShapeBuffer(Mesh sourceMesh) + public BlendShapeBuffer(Mesh sourceMesh, List<(string name, float weight)> blendShapes) { Profiler.BeginSample("BlendShapeBuffer:Create"); var totalFrames = 0; @@ -1254,12 +1250,43 @@ public BlendShapeBuffer(Mesh sourceMesh) Profiler.EndSample(); } - Shapes.Add(name, new BlendShapeShape(frameInfos)); + if (!Shapes.TryAdd(name, new BlendShapeShape(frameInfos))) + { + // duplicated blendShape name detected. + // This can be generated with 3ds Max or other tools. + // Rename blendShape a little to avoid conflict. + name = $"{name}-nameConflict-{GetShortRandom()}"; + Shapes.Add(name, new BlendShapeShape(frameInfos)); + } + blendShapes.Add((name, 0.0f)); Profiler.EndSample(); } Profiler.EndSample(); } + private static string GetShortRandom() + { + // generate 4-char base64 string + // with 4 of 64 characters, it has 64^4 = 16777216 possibilities. + // When we create 100 times, the possibility of collision is one in about 3390 + var chars = new char[4]; + + for (var i = 0; i < chars.Length; i++) + chars[i] = Base64Char(Random.Range(0, 64)); + return new string(chars); + + static char Base64Char(int value) => value switch + { + < 0 => throw new ArgumentOutOfRangeException(nameof(value), $"{value}"), + < 10 => (char)('0' + value), + < 36 => (char)('A' + value - 10), + < 62 => (char)('a' + value - 36), + 62 => '-', + 63 => '_', + _ => throw new ArgumentOutOfRangeException(nameof(value), $"{value}"), + }; + } + // create empty private BlendShapeBuffer() { diff --git a/Test~/MeshInfo2/MeshInfo2Test.cs b/Test~/MeshInfo2/MeshInfo2Test.cs index 5139f935..8bbdc533 100644 --- a/Test~/MeshInfo2/MeshInfo2Test.cs +++ b/Test~/MeshInfo2/MeshInfo2Test.cs @@ -217,5 +217,59 @@ public void ComputeActualPositionWithBones() Assert.That(position, Is.EqualTo(vertex.Position)); } } + + // test with binary-edited fbx which is originally exported from blender + [Test] + public void MultipleSameNameBlendShapeBlenderBinaryEdited() + { + var fbx = TestUtils.GetAssetAt("MeshInfo2/same-name-blendshape-blender-binary-edited.fbx"); + var renderer = fbx.GetComponent(); + var mesh = renderer.sharedMesh; + + // check the mesh has same name blendShape + Assert.That(mesh.blendShapeCount, Is.EqualTo(2)); + Assert.That(mesh.GetBlendShapeName(0), Is.EqualTo("BlendShape1")); + Assert.That(mesh.GetBlendShapeName(1), Is.EqualTo("BlendShape1")); + + using var meshInfo2 = new MeshInfo2(renderer); + // we've checked no exception is thrown + + // second shape is renamed + Assert.That(meshInfo2.BlendShapes[0].name, Is.EqualTo("BlendShape1")); + Assert.That(meshInfo2.BlendShapes[1].name, Does.StartWith("BlendShape1-nameConflict-")); + // and there is buffer for each vertex + foreach (var vertex in meshInfo2.Vertices) + { + Assert.That(vertex.BlendShapeBuffer.Shapes[meshInfo2.BlendShapes[0].name], Is.Not.Null); + Assert.That(vertex.BlendShapeBuffer.Shapes[meshInfo2.BlendShapes[1].name], Is.Not.Null); + } + } + + // test with real fbx + [Test] + public void MultipleSameNameBlendShape3dsMax() + { + var fbx = TestUtils.GetAssetAt("MeshInfo2/same-name-blendshape-3ds-max.fbx"); + var renderer = fbx.transform.Find("Box001").GetComponent(); + var mesh = renderer.sharedMesh; + + // check the mesh has same name blendShape + Assert.That(mesh.blendShapeCount, Is.EqualTo(2)); + Assert.That(mesh.GetBlendShapeName(0), Is.EqualTo("Shape")); + Assert.That(mesh.GetBlendShapeName(1), Is.EqualTo("Shape")); + + using var meshInfo2 = new MeshInfo2(renderer); + // we've checked no exception is thrown + + // second shape is renamed + Assert.That(meshInfo2.BlendShapes[0].name, Is.EqualTo("Shape")); + Assert.That(meshInfo2.BlendShapes[1].name, Does.StartWith("Shape-nameConflict-")); + // and there is buffer for each vertex + foreach (var vertex in meshInfo2.Vertices) + { + Assert.That(vertex.BlendShapeBuffer.Shapes[meshInfo2.BlendShapes[0].name], Is.Not.Null); + Assert.That(vertex.BlendShapeBuffer.Shapes[meshInfo2.BlendShapes[1].name], Is.Not.Null); + } + } } } diff --git a/Test~/MeshInfo2/same-name-blendshape-3ds-max.fbx b/Test~/MeshInfo2/same-name-blendshape-3ds-max.fbx new file mode 100644 index 00000000..c2afe2ba Binary files /dev/null and b/Test~/MeshInfo2/same-name-blendshape-3ds-max.fbx differ diff --git a/Test~/MeshInfo2/same-name-blendshape-3ds-max.fbx.meta b/Test~/MeshInfo2/same-name-blendshape-3ds-max.fbx.meta new file mode 100644 index 00000000..15476028 --- /dev/null +++ b/Test~/MeshInfo2/same-name-blendshape-3ds-max.fbx.meta @@ -0,0 +1,109 @@ +fileFormatVersion: 2 +guid: 7ca8f16ddc64441cbaf1855da11db6ea +ModelImporter: + serializedVersion: 22200 + internalIDToNameTable: [] + externalObjects: {} + materials: + materialImportMode: 2 + materialName: 0 + materialSearch: 1 + materialLocation: 1 + animations: + legacyGenerateAnimations: 4 + bakeSimulation: 0 + resampleCurves: 1 + optimizeGameObjects: 0 + removeConstantScaleCurves: 0 + motionNodeName: + rigImportErrors: + rigImportWarnings: + animationImportErrors: + animationImportWarnings: + animationRetargetingWarnings: + animationDoRetargetingWarnings: 0 + importAnimatedCustomProperties: 0 + importConstraints: 0 + animationCompression: 1 + animationRotationError: 0.5 + animationPositionError: 0.5 + animationScaleError: 0.5 + animationWrapMode: 0 + extraExposedTransformPaths: [] + extraUserProperties: [] + clipAnimations: [] + isReadable: 0 + meshes: + lODScreenPercentages: [] + globalScale: 1 + meshCompression: 0 + addColliders: 0 + useSRGBMaterialColor: 1 + sortHierarchyByName: 1 + importPhysicalCameras: 1 + importVisibility: 1 + importBlendShapes: 1 + importCameras: 1 + importLights: 1 + nodeNameCollisionStrategy: 1 + fileIdsGeneration: 2 + swapUVChannels: 0 + generateSecondaryUV: 0 + useFileUnits: 1 + keepQuads: 0 + weldVertices: 1 + bakeAxisConversion: 0 + preserveHierarchy: 0 + skinWeightsMode: 0 + maxBonesPerVertex: 4 + minBoneWeight: 0.001 + optimizeBones: 1 + meshOptimizationFlags: -1 + indexFormat: 0 + secondaryUVAngleDistortion: 8 + secondaryUVAreaDistortion: 15.000001 + secondaryUVHardAngle: 88 + secondaryUVMarginMethod: 1 + secondaryUVMinLightmapResolution: 40 + secondaryUVMinObjectScale: 1 + secondaryUVPackMargin: 4 + useFileScale: 1 + strictVertexDataChecks: 0 + tangentSpace: + normalSmoothAngle: 60 + normalImportMode: 0 + tangentImportMode: 3 + normalCalculationMode: 4 + legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0 + blendShapeNormalImportMode: 1 + normalSmoothingSource: 0 + referencedClips: [] + importAnimation: 1 + humanDescription: + serializedVersion: 3 + human: [] + skeleton: [] + armTwist: 0.5 + foreArmTwist: 0.5 + upperLegTwist: 0.5 + legTwist: 0.5 + armStretch: 0.05 + legStretch: 0.05 + feetSpacing: 0 + globalScale: 1 + rootMotionBoneName: + hasTranslationDoF: 0 + hasExtraRoot: 0 + skeletonHasParents: 1 + lastHumanDescriptionAvatarSource: {instanceID: 0} + autoGenerateAvatarMappingIfUnspecified: 1 + animationType: 2 + humanoidOversampling: 1 + avatarSetup: 0 + addHumanoidExtraRootOnlyWhenUsingAvatar: 1 + importBlendShapeDeformPercent: 1 + remapMaterialsIfMaterialImportModeIsNone: 0 + additionalBone: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Test~/MeshInfo2/same-name-blendshape-blender-binary-edited.fbx b/Test~/MeshInfo2/same-name-blendshape-blender-binary-edited.fbx new file mode 100644 index 00000000..8e1ed699 Binary files /dev/null and b/Test~/MeshInfo2/same-name-blendshape-blender-binary-edited.fbx differ diff --git a/Test~/MeshInfo2/same-name-blendshape-blender-binary-edited.fbx.meta b/Test~/MeshInfo2/same-name-blendshape-blender-binary-edited.fbx.meta new file mode 100644 index 00000000..4d643e93 --- /dev/null +++ b/Test~/MeshInfo2/same-name-blendshape-blender-binary-edited.fbx.meta @@ -0,0 +1,109 @@ +fileFormatVersion: 2 +guid: f03bf31712e364e1387d197f42586cc9 +ModelImporter: + serializedVersion: 22200 + internalIDToNameTable: [] + externalObjects: {} + materials: + materialImportMode: 2 + materialName: 0 + materialSearch: 1 + materialLocation: 1 + animations: + legacyGenerateAnimations: 4 + bakeSimulation: 0 + resampleCurves: 1 + optimizeGameObjects: 0 + removeConstantScaleCurves: 0 + motionNodeName: + rigImportErrors: + rigImportWarnings: + animationImportErrors: + animationImportWarnings: + animationRetargetingWarnings: + animationDoRetargetingWarnings: 0 + importAnimatedCustomProperties: 0 + importConstraints: 0 + animationCompression: 1 + animationRotationError: 0.5 + animationPositionError: 0.5 + animationScaleError: 0.5 + animationWrapMode: 0 + extraExposedTransformPaths: [] + extraUserProperties: [] + clipAnimations: [] + isReadable: 0 + meshes: + lODScreenPercentages: [] + globalScale: 1 + meshCompression: 0 + addColliders: 0 + useSRGBMaterialColor: 1 + sortHierarchyByName: 1 + importPhysicalCameras: 1 + importVisibility: 1 + importBlendShapes: 1 + importCameras: 1 + importLights: 1 + nodeNameCollisionStrategy: 1 + fileIdsGeneration: 2 + swapUVChannels: 0 + generateSecondaryUV: 0 + useFileUnits: 1 + keepQuads: 0 + weldVertices: 1 + bakeAxisConversion: 0 + preserveHierarchy: 0 + skinWeightsMode: 0 + maxBonesPerVertex: 4 + minBoneWeight: 0.001 + optimizeBones: 1 + meshOptimizationFlags: -1 + indexFormat: 0 + secondaryUVAngleDistortion: 8 + secondaryUVAreaDistortion: 15.000001 + secondaryUVHardAngle: 88 + secondaryUVMarginMethod: 1 + secondaryUVMinLightmapResolution: 40 + secondaryUVMinObjectScale: 1 + secondaryUVPackMargin: 4 + useFileScale: 1 + strictVertexDataChecks: 0 + tangentSpace: + normalSmoothAngle: 60 + normalImportMode: 0 + tangentImportMode: 3 + normalCalculationMode: 4 + legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0 + blendShapeNormalImportMode: 1 + normalSmoothingSource: 0 + referencedClips: [] + importAnimation: 1 + humanDescription: + serializedVersion: 3 + human: [] + skeleton: [] + armTwist: 0.5 + foreArmTwist: 0.5 + upperLegTwist: 0.5 + legTwist: 0.5 + armStretch: 0.05 + legStretch: 0.05 + feetSpacing: 0 + globalScale: 1 + rootMotionBoneName: + hasTranslationDoF: 0 + hasExtraRoot: 0 + skeletonHasParents: 1 + lastHumanDescriptionAvatarSource: {instanceID: 0} + autoGenerateAvatarMappingIfUnspecified: 1 + animationType: 2 + humanoidOversampling: 1 + avatarSetup: 0 + addHumanoidExtraRootOnlyWhenUsingAvatar: 1 + importBlendShapeDeformPercent: 1 + remapMaterialsIfMaterialImportModeIsNone: 0 + additionalBone: 0 + userData: + assetBundleName: + assetBundleVariant: