换装原理详解

在讲述换装之前,我们先了解几个概念

什么是骨骼

在这里插入图片描述
如图所示,美术模型导入Unity中时会自动转换为transform形式的节点,即骨骼,一般名称带root的表示根骨骼

模型是怎么动的

在这里插入图片描述
animation中记录每帧对应动作的骨骼的Position或者Scale,每帧连成一个整体便是动画,即K帧

什么是蒙皮

蒙皮是美术中的术语,把模型绑定到骨骼上的技术叫做蒙皮,用骨骼的活动来带动模型的活动
在这里插入图片描述
(骨骼拉扯,带动蒙皮)

Unity中是如何实现模型的蒙皮

我们可以将模型理解为两块组成Mesh和材质,Unity在导出Fbx时会自动生成SkinnedMeshRenderer,对应模型的Mesh、Materail、骨骼等会记录在 SkinnedMeshRenderer中,其中Mesh的每个顶点会绑定的一个至多个骨骼(unity里面是最多4个),动画播放时,顶点随着关节运动,顶点的最终变换就等于它所绑定的骨架变换的加权和。能把网格顶点从原来位置(绑定姿势)变换至骨骼的当前姿势的矩阵称为蒙皮矩阵。蒙皮矩阵把顶点变形至新位置,顶点在变换前后都在模型变换空间中。

几个要点

  1. Mesh中的boneWeights属性对应每个顶点所对应的骨骼和权重
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; }
  1. Mesh中使用的骨骼的变换矩阵(每根bone从mesh空间到自己的bone空间的变换矩阵)记录在bindpose中
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推荐的方式)

以下开始对这几种换装作详细描述

一.不受到蒙皮影响型换装

此类换装因为不受到蒙皮影响所以一般不需要美术做相关协助

1.更换材质

这里的更换材质是广义上的材质,并不单指material,通常的功能表现为时装染色,我们参考一个市面上的游戏作为例子(忽略水印和图上的备注红字)
在这里插入图片描述
在这里插入图片描述

  • 染色:改变Color值
  • 花纹:改变贴图
  • 花纹大小:改变Tiling值

效果
在这里插入图片描述

*(以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,这部分区域我们不再混合颜色,而是直接对原纹理进行采样。)

2.骨骼挂接

这个比较简单,一般适用于武器/饰品等,我们预先在骨骼下添加一个节点然后动态更换指定物件即可
在这里插入图片描述
没有动作的骨骼挂接,适合武器、背饰等
有动作的骨骼挂接,适合坐骑

二.受蒙皮影响型换装

受到蒙皮影响的位置一般为身体部件(头、手、腿、脚等),我们的换装主要是对这几个部位进行动态更换,所以美术制作时需要对换装的部件进行蒙皮处理,即每个部件都带有SkinedMeshRenderer,这里在拿到Fbx文件时可以按下图所示分解
在这里插入图片描述

  • 导出骨骼作为主体Prefab,进行后续换装
  • 单独导出带有SkinedMeshRenderer每个子部件,一般为方便会把整个骨骼一并导出来到Prefab中,虽然子部件受影响的骨骼很少,但是剔除不受影响的骨骼这一操作很麻烦

1.共享骨骼

基于以上一系列原理,我们不难想到,将主骨骼附上动画组件,模型动画控住主骨骼,同时将需要换的部件加载到主骨骼上,然后将每个部件上的SkinnedMeshRenderer所影响的骨骼替换为主骨骼上的同名骨骼即可达到动态蒙皮的效果,这便是共享骨骼

直接贴代码!

/// <summary>
/// 共享骨骼
/// </summary>
/// <param name="selfSkin">子部件</param>
/// <param name="mainSkeleton">主骨骼</param>
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;
}

这种换装做法简单方便,在端游中很常见

2.Mesh合并

最后一种换装较为复杂一点也是Unity官方推荐的一套。
我们在共享骨骼的基础上进行如下优化

1.合并SkinnedMeshRenderer

通常为了减少渲染过程中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等关键信息也合并到一个数组中(并没有进行同名剔除)

2. 合并相同材质的材质球

一般为了达到优化,降低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];
  • 2
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值