开始换装之前,首先需要了解一些概念.
参考部分 以下内容转载自博客:
https://sunra.top/2021/11/07/uniyt-change-suit/
骨骼,蒙皮和动画 目前游戏开发中常用的两种动画:顶点动画和蒙皮动画
顶点动画
通过在动画帧中直接修改mesh顶点的位置来实现,通常在mesh顶点数目较少,动画简单的情况下使用,如草的摆动,树的摆动,水的波动等
蒙皮动画
通过在动画中直接修改bone的位置,让mesh的顶点随着bone的变化而变化,通常用于人形动画,如人物的跑动,跳跃等
骨骼是什么 当我们倒入带有骨骼的Model时,我们可以在其中发现一个嵌套的GameObject,这个GamoeObject以及它的所有的子GameObject都只有一个属性,就是transforms的坐标信息,这些坐标信息组成了该模型的骨骼信息。
蒙皮是什么 我们知道Mesh是由顶点和面组成的,如果不绑定蒙皮数据,称之为静态mesh,不具有动画效果的,如游戏中的房子,地面,桥,道路等;
对于绑定蒙皮的mesh,我们称之为SkinMesh,在SkinMesh中每个mesh的顶点会受到若干个骨骼的影响,并配以一定的权重比例;
就像我们真实的人一样,首先支撑并决定位置和运动的是一组骨骼,头+身体+四肢,而身上的肌肉是受到骨骼的影响而产生运动的,每一块肌肉的运动可能会受到多个骨骼的影响;
蒙皮中需要的数据 在unity中主要是通过SkinnedMeshRenderer组件来实现蒙皮动画的计算
计算蒙皮动画所需要的数据: SkinnedMeshRenderer.bones:所有引用到bone的列表,注意顺序是确定的,后续顶点的BoneWeight中bone的索引,就是基于这个数组顺序的索引 SkinnedMeshRenderer.sharedMesh:渲染所需的mesh数据,注意相比普通的MeshRender所需的顶点和面数据,还会有一些额外的计算蒙皮相关的数据 Mesh.boneWeights:每个顶点受到哪几根bone的影响的索引和权重(每个顶点最多受到四根骨骼的影响,详见结构体BoneWeight的定义) Mesh.bindposes:每根bone从mesh空间到自己的bone空间的变换矩阵,也就是预定义的bone的bone空间到mesh空间的变换矩阵的逆矩阵,注意顶点受到bone影响所做的变换都是基于在bone空间做的变换
根据Unity文档, Unity中BindPose的算法如下:
OneBoneBindPose = bone.worldToLocalMatrix * transform.localToWorldMatrix; 骨骼的世界转局部坐标系矩阵乘上Mesh的局部转世界矩阵
注意:美术一般在绑定蒙皮时,会将骨骼摆成一个Tpose的样式,这个时候的bone的transform转换出矩阵也就是bindpose,所有的骨骼动画都是在这个基础上相对变换的,最终会作为mesh本身的静态数据保存下来。
SkinnedMeshRenderer是一种不同于普通Mesh Renderer的渲染器,普通的渲染器是使用Mesh Filter定义的网格信息加上Material(会指定使用的Shader)对GameObject进行渲染,而SkinnedMeshRenderer不同,它还会有bones等属性,用于表明对应的骨骼信息。 这些信息都是在导入模型时自带的,在建模工具中就会定好每个点受到每个骨骼信息的影响,即当骨骼中的某个点位置发生改变时,相应的Mesh如何变化。
换装的两种方式 替换SkinnedMeshRender的方式 主要适用于衣服,裤子,发型等
替换步骤:
1:一般根据是否单独部位可以换装,将单独部位或者整套模型制作成一个Prefab
2:加载prefab,并实例化为GameObject
3:查找到新实例化的SkinnedMeshRender对应的原有的SkinnedMeshRender
4:替换bones
5:替换mesh
6:替换material
7:替换完成,销毁新实例化的Prefab
节点挂载的方式 主要适用于武器,翅膀,尾巴等
替换步骤:
1:一般将单独一套模型制作成一个Prefab
2:加载prefab,并实例化为GameObject
3:查找挂点(比如武器一般会挂载在手的骨骼节点上)
4:销毁原有的装备
5:将实例化的GameObject的父节点设置为挂点
6:设置好GameObject的偏移,缩放,旋转(一般都为0)
个人实现 替换smr 思路 我使用的是第一种方式,替换smr中的蒙皮和骨骼,材质.因为是那套素材的材质是通用的,所以无需替换材质.
首先我是在b站找了siki学院的视频,虽然是好几年前的教程了,小姐姐使用的是嵌套字典来存储模型数据.
大概思路如下:
首先准备一套初始模型(Player),还有一套只有骨骼的目标模型(PlayerTarget),分别做成预制体.放在assets下新建的Resources文件夹内(主要用于代码从这寻找生成);
其次通过代码在世界中生成这两套模型,初始带着若干装备的模型默认setActivity(false);
然后将这套模型的SkinnedMeshRenderer存储在字典中,通过部件名+编号的形式 嵌套存储;
在目标模型对象中读取字典中的SkinnedMeshRenderer中的bones、materials、sharedMesh并替换,完成换装。
初始模型:
实现 首先创建一个新场景,随意摆个平面+两面墙
然后在合适位置制作player和playertarget,拖下去作为预制体,并且删除.(注意这俩模型的位置要一致)
新建AvatarSys 空物体,挂载脚本AvatarSys.cs .
新建脚本AvatarSysData.cs 继承自SriptableObject ,用来存储模型对象上读取的数据,在资源文件夹中可右键创建;
AvatarSysData: using System.Collections;using System.Collections.Generic;using UnityEngine;using UnityEngine.InputSystem.Interactions;[CreateAssetMenu(menuName = "Data/PlayerSkin" , fileName = "AvatarSysData_" ) ] public class AvatarSysData : ScriptableObject { public Dictionary<string , Dictionary<string , SkinnedMeshRenderer>> PLayerSkinData = new Dictionary<string , Dictionary<string , SkinnedMeshRenderer>>(); public Transform[] playerBoneTrans; public Dictionary<string , SkinnedMeshRenderer> PlayerTargetData = new Dictionary<string , SkinnedMeshRenderer>(); public Transform playerSourceTrans; public GameObject playerTarget; public string [,] playerStr = new string [,] { { "Belt" , "1" }, { "Cloth" , "1" }, { "Face" , "1" }, { "Glove" , "1" }, { "HairHalf" , "1" }, { "Hat" , "1" }, { "Shoe" , "1" }, { "ShoulderPad" , "1" } }; public SkinnedMeshRenderer[] saveSmr; public SkinnedMeshRenderer[] SaveParts (SkinnedMeshRenderer[] s ) { saveSmr = s; return saveSmr; } }
AvatarSys.cs: 代码内容如下:包含了存储模型信息,以及读取更换服装等功能
using System.Text.RegularExpressions;using System.Collections;using System.Collections.Generic;using UnityEngine;public class AvatarSys : MonoBehaviour { [SerializeField ] AvatarSysData avatarSysData; GameObject Modelgo; private void OnEnable () { InstantiateSource(); InstantiateTarget(); SaveData(); InitAvatar(); } private void Start () { DontDestroyOnLoad(avatarSysData.playerTarget); } void InstantiateSource () { Modelgo = Instantiate(Resources.Load("Player" )) as GameObject; avatarSysData.playerSourceTrans = Modelgo.transform; Modelgo.SetActive(false ); } void InstantiateTarget () { avatarSysData.playerTarget = Instantiate(Resources.Load("PlayerTarget" )) as GameObject; avatarSysData.playerBoneTrans = avatarSysData.playerTarget.GetComponentsInChildren<Transform>(); } void SaveData () { if (avatarSysData.playerSourceTrans == null ) { return ; } SkinnedMeshRenderer[] parts = avatarSysData.playerSourceTrans.GetComponentsInChildren<SkinnedMeshRenderer>(); foreach (var part in parts) { string PartsNum = Regex.Replace(part.name, "[a-z]" , "" , RegexOptions.IgnoreCase); string PartsName = Regex.Replace(part.name, "[0-9]" , "" , RegexOptions.IgnoreCase); if (!avatarSysData.PLayerSkinData.ContainsKey(PartsName)) { GameObject partGo = new GameObject(); partGo.name = PartsName; partGo.transform.parent = avatarSysData.playerTarget.transform; avatarSysData.PlayerTargetData.Add(PartsName, partGo.AddComponent<SkinnedMeshRenderer>()); avatarSysData.PLayerSkinData.Add(PartsName, new Dictionary<string , SkinnedMeshRenderer>()); } avatarSysData.PLayerSkinData[PartsName].Add(PartsNum, part); } } public void ChangeMesh (string part, string num ) { SkinnedMeshRenderer skm = avatarSysData.PLayerSkinData[part][num]; List<Transform> bonesList = new List<Transform>(); foreach (var skmbone in skm.bones) { foreach (var targetbone in avatarSysData.playerBoneTrans) { if (targetbone.name == skmbone.name) { bonesList.Add(targetbone); break ; } } } avatarSysData.PlayerTargetData[part].bones = bonesList.ToArray(); avatarSysData.PlayerTargetData[part].materials = skm.materials; avatarSysData.PlayerTargetData[part].sharedMesh = skm.sharedMesh; } public void InitAvatar () { int length = avatarSysData.playerStr.GetLength(0 ); for (int i = 0 ; i < length; i++) { ChangeMesh(avatarSysData.playerStr[i, 0 ], avatarSysData.playerStr[i, 1 ]); } } }
在游戏场景中读取数据 新建ui,注意部件名要和字典中key一致
UI上绑定两个脚本,实现的功能分别是:变换服装(BtnChange.cs),保存跳转(BtnSave.cs)
BtnChange.cs: 变装实现思路是:通过列表获取按键,循环添加按钮监听器.通过点击按钮,调用avatarSys中ChangeMesh(),这是换装函数;参数是部位,数字,这部分通过按钮名字和点击次数n实现.
using System;using System.Collections;using System.Collections.Generic;using UnityEngine;using UnityEngine.UI;public class BtnChange : MonoBehaviour { [SerializeField ] AvatarSysData avatarSysData; [SerializeField ] AvatarSys avatarSys; [SerializeField ] List<Button> listBtnChangeParts; [SerializeField ] Button btnReset; int n = 1 ; private void Awake () { for (int i = 0 ; i < listBtnChangeParts.Count; i++) { listBtnChangeParts[i].onClick.AddListener(ChangeShin); } } private void OnEnable () { btnReset.onClick.AddListener(Reset); } private void OnDisable () { btnReset.onClick.RemoveListener(Reset); } void ChangeShin () { var button = UnityEngine.EventSystems.EventSystem.current.currentSelectedGameObject; int partCount = avatarSysData.PLayerSkinData[button.name].Count; if (n >= partCount) { n = 0 ; avatarSys.ChangeMesh(button.name, "1" ); } n++; avatarSys.BtnChange(button.name, n.ToString()); } void Reset () { for (int i = 0 ; i < listBtnChangeParts.Count; i++) { avatarSys.ChangeMesh(listBtnChangeParts[i].name, "1" ); } n = 1 ; } }
BtnSave.cs: 保存数据就是将SkinnedMeshRenderer数据存入AvatarSysData,游戏场景中读取时只需要读sharedMesh即可,因为materials是全部件共用一个,bone因为是同骨骼所以不需要换.
using System;using System.Collections;using System.Collections.Generic;using UnityEngine;using UnityEngine.UI;public class BtnSave : MonoBehaviour { [SerializeField ] AvatarSysData avatarSysData; [SerializeField ] Button btnSave; [SerializeField ] Button btnJump; private void OnEnable () { btnSave.onClick.AddListener(OnSave); btnJump.onClick.AddListener(SceneLoader.LoadGameScene); } private void OnDisable () { btnSave.onClick.RemoveListener(OnSave); btnJump.onClick.RemoveListener(SceneLoader.LoadGameScene); } public void OnSave () { GameObject player = GameObject.FindWithTag("SkinModel" ); SkinnedMeshRenderer[] smr = player.GetComponentsInChildren<SkinnedMeshRenderer>(); avatarSysData.SaveParts(smr); Debug.Log("保存" ); } }
读取数据换装 First Scene中玩家对象Player下的GamePlayer 模型里创建一个脚本ChangePlayerSkin.cs
获取当前主角的sharedmesh替换成保存数据中的sharedmesh即可.
using System.Collections;using System.Collections.Generic;using System.Linq;using TreeEditor;using UnityEngine;public class ChangePlayerSkin : MonoBehaviour { [SerializeField ] AvatarSysData avatarSysData; SkinnedMeshRenderer[] smr; private void Awake () { smr = gameObject.GetComponentsInChildren<SkinnedMeshRenderer>(); } private void OnEnable () { if (GameObject.FindWithTag("SkinModel" )) { GameObject.FindWithTag("SkinModel" ).SetActive(false ); } } private void Start () { for (int i = 0 ; i < avatarSysData.saveSmr.Length; i++) { smr[i].sharedMesh = avatarSysData.saveSmr[i].sharedMesh; } } }
总结 第一次使用scriptableobject来存储,做完发现其实用json存档可能会更合适点,因为这里从不同场景进入换装场景都会初始化皮肤.用json存档的话可以判断key来读取存档,实现数据持久化.以后有时间可以尝试下.