// Copyright (c) 2024 Synty Studios Limited. All rights reserved. // // Use of this software is subject to the terms and conditions of the Synty Studios End User Licence Agreement (EULA) // available at: https://syntystore.com/pages/end-user-licence-agreement // // For additional details, see the LICENSE.MD file bundled with this software. using Synty.SidekickCharacters.Utils; using System; using System.Collections; using System.Collections.Generic; using UnityEngine; namespace Synty.SidekickCharacters.SkinnedMesh { /// /// Combines a set of given SkinnedMeshRenderers into a single SkinnedMEshRenderer. /// public static class Combiner { /// /// Merges meshes together, including maintaining blend shape data. /// /// Meshes to merge. /// The mesh to merge everything into. /// The SkinnedMeshRenderer to attach the combined mesh to. public static void MergeAndGetAllBlendShapeDataOfSkinnedMeshRenderers( SkinnedMeshRenderer[] skinnedMeshesToMerge, Mesh finalMesh, SkinnedMeshRenderer finalSkinnedMeshRenderer ) { List allBlendShapeData = new List(); //Verify each skinned mesh renderer and get info about all blendshapes of all meshes int totalVerticesVerifiedAtHereForBlendShapes = 0; foreach (SkinnedMeshRenderer combine in skinnedMeshesToMerge) { // Skip any parts that have not been assigned if (combine == null) { continue; } List newData = BlendShapeUtils.GetBlendShapeData( finalMesh, combine, Array.Empty(), totalVerticesVerifiedAtHereForBlendShapes, allBlendShapeData ); //Set vertices verified at here, after processing all blendshapes for this mesh totalVerticesVerifiedAtHereForBlendShapes += combine.sharedMesh.vertexCount; } BlendShapeUtils.RestoreBlendShapeData(allBlendShapeData, finalMesh, finalSkinnedMeshRenderer); } /// /// Processes the given GameObject and combines the objects contained into combined meshes grouped by their material. /// Then returns a new GameObject with the combined data. /// /// All of the meshes to combine into a single model /// The base model that has the base rig and where the skinned meshes will be combined to. /// The base material to use for the combined model. /// A new GameObject containing all the combined objects, grouped and combined by Material. public static GameObject CreateCombinedSkinnedMesh( List skinnedMeshesToCombine, GameObject baseModel, Material baseMaterial ) { // Create the new base GameObject. This will store all the combined meshes. GameObject combinedModel = new GameObject("Prefab Character"); GameObject combinedSkinnedMesh = new GameObject("mesh"); combinedSkinnedMesh.transform.parent = combinedModel.transform; skinnedMeshesToCombine.Sort((a, b) => string.Compare(a.name, b.name)); Material material = null; Mesh mesh = new Mesh(); GameObject rootBone = BuildSkeleton(skinnedMeshesToCombine, baseModel, combinedModel.transform, out _); // Build the unique skeleton; one entry per unique bone name, ordered by rig hierarchy traversal. HashSet usedBoneNames = new HashSet(); foreach (SkinnedMeshRenderer child in skinnedMeshesToCombine) { foreach (Transform bone in child.bones) { usedBoneNames.Add(bone.name); } } List uniqueBoneList = new List(); Dictionary boneIndexByName = new Dictionary(); CollectUsedBonesDepthFirst(rootBone.transform, usedBoneNames, uniqueBoneList, boneIndexByName); List combineInstances = new List(); List allBoneWeights = new List(); // Iterate through the skinned meshes and process them into Material groupings, and remap their bone weights to the unique skeleton. foreach (SkinnedMeshRenderer child in skinnedMeshesToCombine) { material = child.sharedMaterial; mesh = MeshUtils.CopyMesh(child.sharedMesh); int[] indexRemap = new int[child.bones.Length]; for (int i = 0; i < indexRemap.Length; i++) { if (!boneIndexByName.TryGetValue(child.bones[i].name, out int uniqueIndex)) { Debug.LogWarning($"Combiner: bone '{child.bones[i].name}' on part '{child.name}' not found in rig; remapping to root."); uniqueIndex = 0; } indexRemap[i] = uniqueIndex; } // Collected separately rather than written back to the copied mesh, as CombineMeshes would offset the indices again. allBoneWeights.AddRange(RemapBoneWeights(mesh.boneWeights, indexRemap)); Matrix4x4 transformMatrix = child.localToWorldMatrix; CombineInstance combineInstance = new CombineInstance(); combineInstance.mesh = mesh; combineInstance.transform = transformMatrix; combineInstances.Add(combineInstance); } SkinnedMeshRenderer renderer = combinedSkinnedMesh.AddComponent(); Transform[] uniqueBones = uniqueBoneList.ToArray(); renderer.bones = uniqueBones; renderer.updateWhenOffscreen = true; Mesh newMesh = new Mesh(); newMesh.CombineMeshes(combineInstances.ToArray(), true, true); newMesh.RecalculateBounds(); newMesh.name = combinedModel.name; renderer.rootBone = rootBone.transform; // Part transforms are baked into the vertices by CombineMeshes, so each bindpose maps from // the combined renderer's space (identity, at the scene origin) into bone space. Matrix4x4 rendererLocalToWorld = combinedSkinnedMesh.transform.localToWorldMatrix; Matrix4x4[] uniqueBindPoses = new Matrix4x4[uniqueBones.Length]; for (int i = 0; i < uniqueBones.Length; i++) { uniqueBindPoses[i] = uniqueBones[i].worldToLocalMatrix * rendererLocalToWorld; } if (allBoneWeights.Count != newMesh.vertexCount) { Debug.LogError($"Combiner: bone weight count {allBoneWeights.Count} != combined vertex count {newMesh.vertexCount}."); } // Assign the remapped weights before shrinking the bindposes so no intermediate state has out-of-range bone indices. newMesh.boneWeights = allBoneWeights.ToArray(); newMesh.bindposes = uniqueBindPoses; renderer.sharedMesh = newMesh; renderer.enabled = true; renderer.sharedMaterial = baseMaterial == null ? material : baseMaterial; MergeAndGetAllBlendShapeDataOfSkinnedMeshRenderers(skinnedMeshesToCombine.ToArray(), renderer.sharedMesh, renderer); return combinedModel; } /// /// Processes the given GameObject and creates a model where each part keeps its own mesh, but all parts share a single /// merged skeleton hierarchy, built the same way as the combined mesh path. /// /// All of the part meshes to add to the model. /// The base model that has the base rig that the parts will be bound to. /// The base material to use for the model. /// A new GameObject containing a renderer per part, all sharing a single skeleton. public static GameObject CreateSeparateSkinnedMeshes( List partsToAdd, GameObject baseModel, Material baseMaterial ) { GameObject partsModel = new GameObject("Prefab Character"); partsToAdd.Sort((a, b) => string.Compare(a.name, b.name)); GameObject rootBone = BuildSkeleton(partsToAdd, baseModel, partsModel.transform, out Hashtable boneNameMap); foreach (SkinnedMeshRenderer part in partsToAdd) { GameObject newPart = new GameObject(part.name); newPart.transform.parent = partsModel.transform; SkinnedMeshRenderer renderer = newPart.AddComponent(); renderer.updateWhenOffscreen = true; // Remap the part's bones to the shared skeleton by name, keeping the part's own bone order so the copied // bone weights and bindposes stay valid without remapping. Transform[] oldBones = part.bones; Transform[] newBones = new Transform[oldBones.Length]; for (int i = 0; i < oldBones.Length; i++) { Transform newBone = (Transform) boneNameMap[oldBones[i].name]; if (newBone == null) { Debug.LogWarning($"Combiner: bone '{oldBones[i].name}' on part '{part.name}' not found in rig; remapping to root."); newBone = rootBone.transform; } newBones[i] = newBone; } renderer.sharedMesh = MeshUtils.CopyMesh(part.sharedMesh); Transform newRootBone = (Transform) boneNameMap[part.rootBone.name]; renderer.rootBone = newRootBone == null ? rootBone.transform : newRootBone; // CopyMesh does not copy blend shapes, so re-add them from the source part. MergeAndGetAllBlendShapeDataOfSkinnedMeshRenderers( new[] { part }, renderer.sharedMesh, renderer ); renderer.bones = newBones; renderer.sharedMaterial = baseMaterial == null ? part.sharedMaterial : baseMaterial; } return partsModel; } /// /// Builds the shared skeleton for a new character model by instantiating the base model's rig and adding any extra /// bones required by the given parts. /// /// The part meshes that will be bound to the skeleton. /// The base model that has the base rig. /// The transform of the new character model the skeleton will be parented to. /// The output map between bone names and the bones of the new skeleton. /// The root bone GameObject of the new skeleton. private static GameObject BuildSkeleton( List parts, GameObject baseModel, Transform characterRoot, out Hashtable boneNameMap ) { Transform modelRootBone = baseModel.GetComponentInChildren().rootBone; GameObject rootBone = GameObject.Instantiate(modelRootBone.gameObject, characterRoot, true); rootBone.name = modelRootBone.name; boneNameMap = CreateBoneNameMap(rootBone); Transform[] additionalBones = FindAdditionalBones(boneNameMap, new List(parts)); if (additionalBones.Length > 0) { JoinAdditionalBonesToBoneArray(Array.Empty(), additionalBones, boneNameMap); // Need to redo the name map now that we have updated the bone array. boneNameMap = CreateBoneNameMap(rootBone); } return rootBone; } /// /// Collects the bones used by the combined parts in rig hierarchy (pre-order depth-first) order, one entry per unique bone name. /// Traversal order and first-wins duplicate name handling match so each indexed /// Transform is the same instance the bone name map resolves to. /// /// The current bone being processed. /// The names of all bones used by the parts being combined. /// The output list of unique bones, in hierarchy order. /// The output map from bone name to its index in . private static void CollectUsedBonesDepthFirst( Transform current, HashSet usedBoneNames, List orderedBones, Dictionary boneIndexByName ) { if (usedBoneNames.Contains(current.name) && !boneIndexByName.ContainsKey(current.name)) { boneIndexByName.Add(current.name, orderedBones.Count); orderedBones.Add(current); } // Recurse into all children regardless; a used bone can sit under an unused one. for (int i = 0; i < current.childCount; i++) { CollectUsedBonesDepthFirst(current.GetChild(i), usedBoneNames, orderedBones, boneIndexByName); } } /// /// Remaps the bone indices of the given bone weights using the given index remap table. /// /// The bone weights to remap. /// A table mapping old bone indices to new bone indices. /// A new array of bone weights with remapped bone indices. private static BoneWeight[] RemapBoneWeights(BoneWeight[] weights, int[] indexRemap) { BoneWeight[] remappedWeights = new BoneWeight[weights.Length]; for (int i = 0; i < weights.Length; i++) { BoneWeight weight = weights[i]; weight.boneIndex0 = indexRemap[weight.boneIndex0]; weight.boneIndex1 = indexRemap[weight.boneIndex1]; weight.boneIndex2 = indexRemap[weight.boneIndex2]; weight.boneIndex3 = indexRemap[weight.boneIndex3]; remappedWeights[i] = weight; } return remappedWeights; } /// /// Processes the movement of bones if required for the given movement dictionary. /// /// The bone name map that has all the bones of the rig. /// The dictionary of bones to process the movement from. /// The dictionary of bone rotations to process. public static void ProcessBoneMovement(Hashtable boneNameMap, Dictionary movementDictionary, Dictionary rotationDictionary) { Dictionary bonePositionDictionary = new Dictionary(); Dictionary boneRotationDictionary = new Dictionary(); Dictionary boneMovementDictionary = new Dictionary(); foreach (Transform currentBone in boneNameMap.Values) { // Store bone positions from rig before processing joints. bonePositionDictionary.TryAdd(currentBone.name, currentBone.transform.localPosition); boneRotationDictionary.TryAdd(currentBone.name, currentBone.transform.localRotation); if (movementDictionary.ContainsKey(currentBone.name)) { float jointDistance = Vector3.Distance(bonePositionDictionary[currentBone.name], movementDictionary[currentBone.name]); float rotationDistance = Quaternion.Angle(boneRotationDictionary[currentBone.name], rotationDictionary[currentBone.name]); // If the bone in the new part is at a different location, move the actual bone to the same position. if (jointDistance > 0.0001) { Vector3 rigMovement = movementDictionary[currentBone.name]; // If an existing joint movement exists, and is further from the standard joint position, use that instead. if (boneMovementDictionary.TryGetValue(currentBone.name, out Vector3 existingMovement) && Math.Abs(Vector3.Distance(bonePositionDictionary[currentBone.name], existingMovement)) > Math.Abs(jointDistance)) { rigMovement = existingMovement; } currentBone.transform.localPosition = rigMovement; boneMovementDictionary[currentBone.name] = rigMovement; } if (rotationDistance > 0.01) { Quaternion rigRotation = rotationDictionary[currentBone.name]; if (boneRotationDictionary.TryGetValue(currentBone.name, out Quaternion existingRotation) && Math.Abs(Quaternion.Angle(boneRotationDictionary[currentBone.name], existingRotation)) > Math.Abs(rotationDistance)) { rigRotation = existingRotation; } currentBone.transform.localRotation = rigRotation; boneRotationDictionary[currentBone.name] = rigRotation; } } } } /// /// Creates a map between bones and their names. /// /// The Current bone being mapped. /// A hashmap between bone names and bones. public static Hashtable CreateBoneNameMap(GameObject currentBone) { Hashtable boneNameMap = new Hashtable(); boneNameMap.Add(currentBone.name, currentBone.transform); for (int i = 0; i < currentBone.transform.childCount; i++) { Hashtable childBoneMap = CreateBoneNameMap(currentBone.transform.GetChild(i).gameObject); foreach (DictionaryEntry entry in childBoneMap) { if (!boneNameMap.ContainsKey(entry.Key)) { boneNameMap.Add(entry.Key, (Transform) entry.Value); } } } return boneNameMap; } /// /// Finds any bones in a given list of SkinnedMeshRenderers that aren't in the given bone map. /// /// The bone map to check for the existence of bones. /// The list of SkinnedMeshRenderers to check for additional bones. /// An array of all additional bones. public static Transform[] FindAdditionalBones(Hashtable boneMap, List meshes) { List newBones = new List(); foreach (SkinnedMeshRenderer mesh in meshes) { foreach (Transform bone in mesh.bones) { if (!boneMap.ContainsKey(bone.name)) { newBones.Add(bone); } } } return newBones.ToArray(); } /// /// Adds additional bones to the current bone array. /// /// The current bone array. /// The new bones to add. /// The current bone name map. /// The new bone array. public static Transform[] JoinAdditionalBonesToBoneArray(Transform[] bones, Transform[] additionBones, Hashtable boneMap) { List fullBones = new List(); fullBones.AddRange(bones); foreach (Transform bone in additionBones) { Transform newParent = (Transform) boneMap[bone.parent.name]; if (newParent != null && !newParent.Find(bone.name)) { GameObject newBone = GameObject.Instantiate(bone.gameObject, newParent); newBone.name = newBone.name.Replace("(Clone)", ""); fullBones.Add(newBone.transform); if (!boneMap.ContainsKey(bone.name)) { boneMap.Add(bone.name, newBone.transform); } } } return fullBones.ToArray(); } } }