在讲述换装之前,我们先了解几个概念
如图所示,美术模型导入Unity中时会自动转换为transform形式的节点,即骨骼,一般名称带root的表示根骨骼
animation中记录每帧对应动作的骨骼的Position或者Scale,每帧连成一个整体便是动画,即K帧
蒙皮是美术中的术语,把模型绑定到骨骼上的技术叫做蒙皮,用骨骼的活动来带动模型的活动
(骨骼拉扯,带动蒙皮)
我们可以将模型理解为两块组成Mesh和材质,Unity在导出Fbx时会自动生成SkinnedMeshRenderer,对应模型的Mesh、Materail、骨骼等会记录在 SkinnedMeshRenderer中,其中Mesh的每个顶点会绑定的一个至多个骨骼(unity里面是最多4个),动画播放时,顶点随着关节运动,顶点的最终变换就等于它所绑定的骨架变换的加权和。能把网格顶点从原来位置(绑定姿势)变换至骨骼的当前姿势的矩阵称为蒙皮矩阵。蒙皮矩阵把顶点变形至新位置,顶点在变换前后都在模型变换空间中。
几个要点
public BoneWeight[] boneWeights { get; set; }
public struct BoneWeight : IEquatable<BoneWeight>
{
public float weight0 { get; set; }
public float weight1 { get; set; }
public float weight2 { get; set; }
public float weight3 { get; set; }
public int boneIndex0 { get; set; }
public int boneIndex1 { get; set; }
public int boneIndex2 { get; set; }
public int boneIndex3 { get; set; }
public Matrix4x4[] bindposes { get; set; }
Unity中BindPose的算法如下:
var oneBoneBindPose = bone.worldToLocalMatrix * transform.localToWorldMatrix;
骨骼的世界转局部坐标系矩阵乘上Mesh的局部转世界矩阵
3. LBS蒙皮算法
for (int vert = 0; vert < verts.Count; ++vert)
{
Vector3 point = verts[vert];
BoneWeight weight = boneWeights[vert];
List<Transform> transSet = bones;
Transform trans0 = bones[weight.boneIndex0];
Transform trans1 = bones[weight.boneIndex1];
Transform trans2 = bones[weight.boneIndex2];
Transform trans3 = bones[weight.boneIndex3];
Matrix4x4 tempMat0 = trans0.localToWorldMatrix * bindPoses[weight.boneIndex0];
Matrix4x4 tempMat1 = trans1.localToWorldMatrix * bindPoses[weight.boneIndex1];
Matrix4x4 tempMat2 = trans2.localToWorldMatrix * bindPoses[weight.boneIndex2];
Matrix4x4 tempMat3 = trans3.localToWorldMatrix * bindPoses[weight.boneIndex3];
Vector3 temp = tempMat0.MultiplyPoint(point) * weight.weights[0] +
tempMat1.MultiplyPoint(point) * weight.weights[1] +
tempMat2.MultiplyPoint(point) * weight.weights[2] +
tempMat3.MultiplyPoint(point) * weight.weights[3];
verts[vert] = srender.transform.worldToLocalMatrix.MultiplyPoint(temp);
}
这里我们按照是否受到蒙皮影响将换装分为两类
更换材质
骨骼挂接
共享骨骼
Mesh合并(Unity推荐的方式)
以下开始对这几种换装作详细描述
此类换装因为不受到蒙皮影响所以一般不需要美术做相关协助
这里的更换材质是广义上的材质,并不单指material,通常的功能表现为时装染色,我们参考一个市面上的游戏作为例子(忽略水印和图上的备注红字)
*(以PBR为例,染色系统的实现不再基于对纹理简单的采样, 而是程序里自定义颜色。shader的属性里设置了R,G,B 三个通道的颜色,可以通过材质Inspector窗口自定义颜色。piexl shader中去混合这些颜色。实际情况中,我们通过uv划分,来支持更多的染色区域。 比如说uv.y 在[1,2]区间可以染色成一种颜色,在uv.y 在[2,3]区间还可以染成另外一种颜色,类似的原理来支持更多的颜色混合。至于颜色混合原码,这里贴出颜色混合的部位核心代码
float3 diffuseColor1 =
(_ColorR.rgb * texColor.r * _ColorR.a +
_ColorG.rgb * texColor.g * _ColorG.a +
_ColorB.rgb * texColor.b * _ColorB.a) * _Color.rgb * float(8);
float2 newuv= float2(i.uv0.x-1,i.uv0.y);
float4 newColor = tex2D(_MainTex,TRANSFORM_TEX(newuv, _MainTex));
float3 diffuseColor2 = (newColor.rgb * _Color.rgb);
float uvlow = step(i.uv0.x, 1);
float uvhigh = 1 - uvlow;
float3 diffuseColor = diffuseColor1 * uvlow + diffuseColor2 * uvhigh;
float alpha = (_ColorR.a + _ColorG.a + _ColorB.a) * 0.7 + uvhigh * 0.3;
使用这套染色系统,对mesh有一定的要求,需要诸如衣服颜色这些固定颜色的部位使用R,G,B中的一种颜色,里面只有灰度变化。对于像皮肤肉色这种变化的且追求细节的部位,纹理绑定的uv.x区间需要超出1,这部分区域我们不再混合颜色,而是直接对原纹理进行采样。)
这个比较简单,一般适用于武器/饰品等,我们预先在骨骼下添加一个节点然后动态更换指定物件即可
没有动作的骨骼挂接,适合武器、背饰等
有动作的骨骼挂接,适合坐骑
受到蒙皮影响的位置一般为身体部件(头、手、腿、脚等),我们的换装主要是对这几个部位进行动态更换,所以美术制作时需要对换装的部件进行蒙皮处理,即每个部件都带有SkinedMeshRenderer,这里在拿到Fbx文件时可以按下图所示分解
基于以上一系列原理,我们不难想到,将主骨骼附上动画组件,模型动画控住主骨骼,同时将需要换的部件加载到主骨骼上,然后将每个部件上的SkinnedMeshRenderer所影响的骨骼替换为主骨骼上的同名骨骼即可达到动态蒙皮的效果,这便是共享骨骼
直接贴代码!
///
/// 共享骨骼
///
/// 子部件
/// 主骨骼
public void ShareSkeletonInstanceWith(SkinnedMeshRenderer selfSkin, GameObject mainSkeleton)
{
Transform[] newBones = new Transform[selfSkin.bones.Length];
for (int i = 0; i < selfSkin.bones.GetLength(0); ++i)
{
GameObject bone = selfSkin.bones[i].gameObject;
// 目标的SkinnedMeshRenderer.bones保存的只是目标mesh相关的骨骼,要获得目标全部骨骼,可以通过查找的方式.
newBones[i] = FindChildRecursion(mainSkeleton.transform, bone.name);
}
selfSkin.bones = newBones;
}
// 递归查找
public Transform FindChildRecursion(Transform t, string name)
{
foreach (Transform child in t)
{
if (child.name == name) return child;
else
{
Transform ret = FindChildRecursion(child, name);
if (ret != null) return ret;
}
}
return null;
}
这种换装做法简单方便,在端游中很常见
最后一种换装较为复杂一点也是Unity官方推荐的一套。
我们在共享骨骼的基础上进行如下优化
通常为了减少渲染过程中CPU唤起Draw Call命令的次数加大对CPU的负载,因为每次Draw Call调用之前CPU都需要为GPU准备好渲染所需要的信息(所用到的材质,纹理,着色器等),我们需要尽可能的减少SkinnedMeshRenderer组件数量,以减少CPU在调用Draw Call之前的一系列准备工作,提高游戏运行效率。
根据开头介绍的SkinnedMeshRender,我们可以得到其3要素:Mesh、Bones、Material,因此合并SkinnedMeshRender需要从这3个方面入手
private void GenerateCombine(AvatarRes avatarres)
{
List<CombineInstance> combineInstances = new List<CombineInstance>();
List<Material> materials = new List<Material>();
List<Transform> bones = new List<Transform>();
// 采集所有当前部件数据
ChangeEquipCombine((int)EPart.EP_Eyes, avatarres, ref combineInstances, ref materials, ref bones);
ChangeEquipCombine((int)EPart.EP_Face, avatarres, ref combineInstances, ref materials, ref bones);
ChangeEquipCombine((int)EPart.EP_Hair, avatarres, ref combineInstances, ref materials, ref bones);
ChangeEquipCombine((int)EPart.EP_Pants, avatarres, ref combineInstances, ref materials, ref bones);
ChangeEquipCombine((int)EPart.EP_Shoes, avatarres, ref combineInstances, ref materials, ref bones);
ChangeEquipCombine((int)EPart.EP_Top, avatarres, ref combineInstances, ref materials, ref bones)
SkinnedMeshRenderer r = mSkeleton.GetComponent<SkinnedMeshRenderer>();
if (r != null) GameObject.DestroyImmediate(r);
r = mSkeleton.AddComponent<SkinnedMeshRenderer>();
r.sharedMesh = new Mesh();
// 以网格列表的形式存储到一个mesh下 并非真正意义上的合并网格
r.sharedMesh.CombineMeshes(combineInstances.ToArray(), false, false);
r.bones = bones.ToArray();
r.materials = materials.ToArray();
}
private void ChangeEquipCombine(GameObject resgo, ref List<CombineInstance> combineInstances,
ref List<Material> materials, ref List<Transform> bones)
{
Transform[] skettrans = mSkeleton.GetComponentsInChildren<Transform>();
// 添加Material
GameObject go = GameObject.Instantiate(resgo);
SkinnedMeshRenderer smr = go.GetComponentInChildren<SkinnedMeshRenderer>();
materials.AddRange(smr.materials);
// 添加Mesh
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 bone in smr.bones)
{
string bonename = bone.name;
foreach (Transform transform in skettrans)
{
if (transform.name != bonename)
continue;
bones.Add(transform);
break;
}
}
GameObject.DestroyImmediate(go);
}
这里子部件的Mesh以subMesh的形式顺序存储到一个Mesh,每个subMesh的对应的bones、bonesWeight、bindPose等关键信息也合并到一个数组中(并没有进行同名剔除)
一般为了达到优化,降低drawcall,还需要合并模型网格,重新计算UV,合并贴图材质。新的步骤:合并网格,合并贴图,重新计算UV,刷新骨骼,附加新材质再设置UV。
其中合并材质重新计算UV,主要代码如下:
//新建一个材质
newMaterial = new Material(Shader.Find("Mobile/Diffuse"));
oldUV = new List<Vector2[]>();
// merge the texture
List<Texture2D> Textures = new List<Texture2D>();
for (int i = 0; i < materials.Count; i++)
{
Textures.Add(materials[i].GetTexture("_MainTex") as Texture2D);
}
newDiffuseTex = new Texture2D(512, 512, TextureFormat.RGBA32, true);
Rect[] uvs = newDiffuseTex.PackTextures(Textures.ToArray(), 0);
newMaterial.mainTexture = newDiffuseTex;
// 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;
}
经过合并处理,我们再给网格渲染器设置新材质再设置UV
SkinnedMeshRenderer r = skeleton.AddComponent<SkinnedMeshRenderer>();
r.sharedMesh = new Mesh();
r.sharedMesh.CombineMeshes(combineInstances.ToArray(), combine, false);// Combine meshes
r.bones = bones.ToArray();
r.material = newMaterial;
for (int i = 0; i < combineInstances.Count; i++)
combineInstances[i].mesh.uv = oldUV[i];