Unity 人物换装实现

最近对Unity的换装方式和原理产生了一定的兴趣,所以,就去网上搜了一些文档,实现了一个简单版本的换装系统,这里简单总结一下。

在正式了解如何进行换装之前,我们需要了解几个概念。

骨骼,蒙皮和动画

目前游戏开发中常用的两种动画:顶点动画和蒙皮动画

  1. 顶点动画
    通过在动画帧中直接修改mesh顶点的位置来实现,通常在mesh顶点数目较少,动画简单的情况下使用,如草的摆动,树的摆动,水的波动等

  2. 蒙皮动画
    通过在动画中直接修改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)

具体实现举例

首先是一种简单的方式,将所有的衣服的SkinnedMeshRenderer都拿出来,然后全部加到骨骼根节点上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
SkinnedMeshRenderer CombineSuitsToSkeleton(GameObject skeleton, SkinnedMeshRenderer[] meshes)
{
var transforms = new List<Transform>(skeleton.GetComponentsInChildren<Transform>(true));

var materials = new List<Material>();

var combineInstances = new List<CombineInstance>();

var bones = new List<Transform>();

foreach (SkinnedMeshRenderer smr in meshes)
{
materials.AddRange(smr.materials);
for (int sub = 0; sub < smr.sharedMesh.subMeshCount; sub++)
{
CombineInstance ci = new CombineInstance();
ci.mesh = smr.sharedMesh;
ci.subMeshIndex = sub;
combineInstances.Add(ci);
}
foreach (Transform b in smr.bones)
{
var t = transforms.Find(t => b.name.Equals(t.name));
if (t != null)
{
bones.Add(t);
}
}
}

var r = skeleton.AddComponent<SkinnedMeshRenderer>() ?? skeleton.GetComponent<SkinnedMeshRenderer>();
r.bones = bones.ToArray();
r.sharedMesh = new Mesh();
r.sharedMesh.CombineMeshes(combineInstances.ToArray(), true, false);
r.materials = materials.ToArray();

return r;
}

通过上述方式,我们会在骨骼的根节点上添加多个Materials,这样在性能上不是最优,我们可以优化一下,将所有衣服的Material都合并起来,然后作为一个整体添加到骨骼上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
SkinnedMeshRenderer CombineSuitsToSkeleton(GameObject skeleton, SkinnedMeshRenderer[] meshes)
{
var transforms = new List<Transform>(skeleton.GetComponentsInChildren<Transform>(true));

var materials = new List<Material>();

var combineInstances = new List<CombineInstance>();

var bones = new List<Transform>();

foreach (SkinnedMeshRenderer smr in meshes)
{
materials.AddRange(smr.materials);
for (int sub = 0; sub < smr.sharedMesh.subMeshCount; sub++)
{
CombineInstance ci = new CombineInstance();
ci.mesh = smr.sharedMesh;
ci.subMeshIndex = sub;
combineInstances.Add(ci);
}
foreach (Transform b in smr.bones)
{
var t = transforms.Find(t => b.name.Equals(t.name));
if (t != null)
{
bones.Add(t);
}
}
}

var newMaterial = new Material(Shader.Find("Standard"));
var oldUV = new List<Vector2[]>();
// merge the texture
var Textures = new List<Texture2D>();
foreach (Material m in materials)
{
Textures.Add(m.GetTexture("_MainTex") as Texture2D);
}

Texture2D newDiffuseTex = new Texture2D(1024, 1024, TextureFormat.RGBA32, true);
Rect[] uvs = newDiffuseTex.PackTextures(Textures.ToArray(), 0);
newMaterial.mainTexture = newDiffuseTex;
newMaterial.EnableKeyword("_SPECULARHIGHLIGHTS_OFF");
newMaterial.SetFloat("_SpecularHighlights", 0f);

// reset uv
Vector2[] uva, uvb;
for (int j = 0; j < combineInstances.Count; j++)
{
uva = (Vector2[])(combineInstances[j].mesh.uv);
uvb = new Vector2[uva.Length];
for (int k = 0; k < uva.Length; k++)
{
uvb[k] = new Vector2((uva[k].x * uvs[j].width) + uvs[j].x, (uva[k].y * uvs[j].height) + uvs[j].y);
}
oldUV.Add(combineInstances[j].mesh.uv);
combineInstances[j].mesh.uv = uvb;
}

var r = skeleton.AddComponent<SkinnedMeshRenderer>() ?? skeleton.GetComponent<SkinnedMeshRenderer>();
r.bones = bones.ToArray();
r.sharedMesh = new Mesh();
r.sharedMesh.CombineMeshes(combineInstances.ToArray(), true, false);
r.material = newMaterial;

return r;
}

代码过程分析

  • 首先获取所有的骨骼信息(骨骼其实就是一个transforms的数组):var transforms = new List(skeleton.GetComponentsInChildren(true));
  • 声明一个新的材质的数组:var materials = new List();
  • 声明一个CombineInstance(Struct used to describe meshes to be combined using Mesh.CombineMeshes)的数组:var combineInstances = new List();
  • 声明一个保存所有骨骼信息的数组:var bones = new List();
  • 然后是一段对meshs数组的循环,作用是将每个mesh的material保存进materials数组,然后将每个mesh的信息保存进combineInstances数组,最后根据名称从body的骨骼信息中获取与mesh的骨骼信息同名的骨骼保存进bones。
  • 这段循环,是将衣服的材质信息保存进combineInstances中,骨骼信息保存进bones中。
  • 获取人物body的SkinnedMeshRenderer,将它的骨骼信息绑定为所有的骨骼,它的mesh为一个新的mesh
  • 创建一个新的材质,它的shader为默认的standard shader
  • 保存旧有的UV信息:var oldUV = new List<Vector2[]>();
  • 保存旧有的贴图信息:
1
2
3
4
5
var Textures = new List<Texture2D>();
foreach (Material m in materials)
{
Textures.Add(m.GetTexture("_MainTex") as Texture2D);
}
  • 创建新的贴图:Texture2D newDiffuseTex = new Texture2D(1024, 1024, TextureFormat.RGBA32, true);
  • 根据旧的贴图数组为新的贴图生成UV信息:Rect[] uvs = newDiffuseTex.PackTextures(Textures.ToArray(), 0);
  • 将新的材质的贴图设置为新的贴图。
  • 根据刚才根据生成的新的UV信息,重新计算将衣服贴图合并以后新贴图的UV信息。

参考文章:

https://zhuanlan.zhihu.com/p/87583171
https://zhuanlan.zhihu.com/p/94134459
https://gameinstitute.qq.com/community/detail/126729