抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

开始换装之前,首先需要了解一些概念.

参考部分

以下内容转载自博客:

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并替换,完成换装。

初始模型:

image-20221122121050160

实现

首先创建一个新场景,随意摆个平面+两面墙

image-20221122135001877

然后在合适位置制作player和playertarget,拖下去作为预制体,并且删除.(注意这俩模型的位置要一致)

新建AvatarSys空物体,挂载脚本AvatarSys.cs.

image-20221122133602404

新建脚本AvatarSysData.cs继承自SriptableObject,用来存储模型对象上读取的数据,在资源文件夹中可右键创建;

AvatarSysData:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem.Interactions;
/// <summary>
/// 存储数据
/// </summary>
[CreateAssetMenu(menuName = "Data/PlayerSkin", fileName = "AvatarSysData_")]
public class AvatarSysData : ScriptableObject
{
/// <summary>
/// 嵌套字典,存储皮肤模型资源信息
/// </summary>
/// <returns></returns>
public Dictionary<string, Dictionary<string, SkinnedMeshRenderer>> PLayerSkinData = new Dictionary<string, Dictionary<string, SkinnedMeshRenderer>>();

/// <summary>
/// 骨骼位置信息
/// </summary>
public Transform[] playerBoneTrans;//玩家骨骼位置信息

/// <summary>
/// 存储换装骨骼上SkinnedMeshRenderer的信息
/// </summary>
/// <typeparam name="string"></typeparam>
/// <typeparam name="SkinnedMeshRenderer"></typeparam>
/// <returns></returns>
public Dictionary<string, SkinnedMeshRenderer> PlayerTargetData = new Dictionary<string, SkinnedMeshRenderer>();

/// <summary>
/// 模型资源位置信息
/// </summary>
public Transform playerSourceTrans;//玩家模型位置信息

/// <summary>
/// 目标模型资源中的骨骼
/// </summary>
public GameObject playerTarget;//目标玩家骨骼

/// <summary>
/// 获取时playerStr[0,0]为"Belt";playerStr[0,1]为"1"
/// </summary>
/// <value></value>
public string[,] playerStr = new string[,] { { "Belt", "1" }, { "Cloth", "1" }, { "Face", "1" }, { "Glove", "1" }, { "HairHalf", "1" }, { "Hat", "1" }, { "Shoe", "1" }, { "ShoulderPad", "1" } };
//{ "Hair", "1" },

public SkinnedMeshRenderer[] saveSmr;
public SkinnedMeshRenderer[] SaveParts(SkinnedMeshRenderer[] s)
{
saveSmr = s;
return saveSmr;
}
}

image-20221122134654704

AvatarSys.cs:

代码内容如下:包含了存储模型信息,以及读取更换服装等功能

//using System.Diagnostics;
using System.Text.RegularExpressions;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class AvatarSys : MonoBehaviour
{

[SerializeField] AvatarSysData avatarSysData;

/// <summary>
/// 原模板模型对象
/// </summary>
GameObject Modelgo;
private void OnEnable()
{
InstantiateSource();
InstantiateTarget();
SaveData();
InitAvatar();
}
private void Start()
{
DontDestroyOnLoad(avatarSysData.playerTarget);//切换场景不会删除
}
/// <summary>
/// 场景中加载资源物体
/// </summary>
void InstantiateSource()
{//加载模型资源物体
Modelgo = Instantiate(Resources.Load("Player")) as GameObject;
avatarSysData.playerSourceTrans = Modelgo.transform;//玩家资源位置信息
Modelgo.SetActive(false);
}
/// <summary>
/// 场景中加载资源物体加载骨骼
/// </summary>
void InstantiateTarget()
{//加载骨骼
avatarSysData.playerTarget = Instantiate(Resources.Load("PlayerTarget")) as GameObject;
avatarSysData.playerBoneTrans = avatarSysData.playerTarget.GetComponentsInChildren<Transform>();
}

/// <summary>
/// 保存信息
/// </summary>
void SaveData()
{//如果无位置信息则返回
if (avatarSysData.playerSourceTrans == null)
{
return;
}
//遍历所有模型资源位置信息子物体,存储SkinnedMeshRenderer
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);

//string[] names = { PartsName, PartsNum };
//判断字典是否包含键,names[0]:string 名字为身体部件名
if (!avatarSysData.PLayerSkinData.ContainsKey(PartsName))
{
//如果不包含此部件名,则在骨骼下新建
//新建空物体
GameObject partGo = new GameObject();
partGo.name = PartsName;//名字为身体部件名
partGo.transform.parent = avatarSysData.playerTarget.transform;//父对象的位置等于骨骼位置
//骨骼字典里存储名字和SkinnedMeshRenderer
avatarSysData.PlayerTargetData.Add(PartsName, partGo.AddComponent<SkinnedMeshRenderer>());
//模型字典里存储名字和新建smr
avatarSysData.PLayerSkinData.Add(PartsName, new Dictionary<string, SkinnedMeshRenderer>());
}
//[模型]字典中根据[部件名]添加数字和SkinnedMeshRenderer
avatarSysData.PLayerSkinData[PartsName].Add(PartsNum, part);
}

}

/// <summary>
/// 更改mesh,part:部位,num:数字
/// </summary>
/// <param name="part"></param>
/// <param name="num"></param>
public void ChangeMesh(string part, string num)
{ //从data里获取对应的skm
//要更换的部位
SkinnedMeshRenderer skm = avatarSysData.PLayerSkinData[part][num];
//存储骨骼位置信息
List<Transform> bonesList = new List<Transform>();
//skm.bones:需要更换部位的骨骼
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;
}
/// <summary>
/// 初始化骨架
/// </summary>
public void InitAvatar()
{//初始化骨架 让他有mesh 材质 骨骼信息
int length = avatarSysData.playerStr.GetLength(0);//0:获取行数;1:获取列
for (int i = 0; i < length; i++)
{//i表示第i行
ChangeMesh(avatarSysData.playerStr[i, 0], avatarSysData.playerStr[i, 1]);
}
//Destroy(Modelgo);//销毁原模型
}
}

在游戏场景中读取数据

新建ui,注意部件名要和字典中key一致

image-20221122135643848

UI上绑定两个脚本,实现的功能分别是:变换服装(BtnChange.cs),保存跳转(BtnSave.cs)

image-20221122135750912

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;//scriptobj存储数据
[SerializeField] AvatarSys avatarSys;

/// <summary>
/// 按钮列表
/// </summary>
[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);
}

/// <summary>
/// 换装
/// </summary>
void ChangeShin()
{
//获取当前按钮
var button = UnityEngine.EventSystems.EventSystem.current.currentSelectedGameObject;
//当前按钮的部件个数
int partCount = avatarSysData.PLayerSkinData[button.name].Count;
//Debug.Log(button.name + n.ToString());
//更换部件
//Debug.Log(button.name);
if (n >= partCount)
{
n = 0;
//Debug.Log(button.name + "超出");
//更改部位为初始值1
avatarSys.ChangeMesh(button.name, "1");
}
n++;
avatarSys.BtnChange(button.name, n.ToString());
}
/// <summary>
/// 重置皮肤
/// </summary>
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);
}

/// <summary>
/// 保存部件
/// </summary>
public void OnSave()
{
GameObject player = GameObject.FindWithTag("SkinModel");
SkinnedMeshRenderer[] smr = player.GetComponentsInChildren<SkinnedMeshRenderer>();
avatarSysData.SaveParts(smr);
Debug.Log("保存");
}
}

读取数据换装

First Scene中玩家对象Player下的GamePlayer模型里创建一个脚本ChangePlayerSkin.cs

image-20221122141305083

获取当前主角的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++)
{
//Debug.Log("部件名:" + avatarSysData.saveSmr[i].sharedMesh.name);
//替换sharemesh
smr[i].sharedMesh = avatarSysData.saveSmr[i].sharedMesh;
}
}
}

总结

第一次使用scriptableobject来存储,做完发现其实用json存档可能会更合适点,因为这里从不同场景进入换装场景都会初始化皮肤.用json存档的话可以判断key来读取存档,实现数据持久化.以后有时间可以尝试下.

评论