当游戏中使用的模型各个部分组成过多时会严重影响运行效率。而往往很多时候这些组合的模型与父类之间都不会产生相互运动,这个时候就可以采取模型合并的策略来进行优化。
目录
3D游戏对象在unity中的组成部分
静态对象的网格合并
蒙皮网格对象的蒙皮合并
unity中3D的游戏对象由网格、材质组成。点开3D对象,我们能看到这个3D模型的是由一些线围成的网格组成,仔细看其中每个网格都是一个三角形。
这就是Unity3D模型中的重要组成部分Mesh——网格。网格由顶点和三角面组成,就如同数学中的几何图形一样,将顶点连起来形成边,围起来的边就是面,在unity中,所有的面都是由三个顶点决定的三角面,因为最少可由三个点确定一个平面,这对于显卡渲染时易于实现的方式。
在unity中,要实现一个3D游戏物体,需要2样东西MeshFilter和Renderer——网格文件和渲染器,前者用于加载网格的数据,后者通过特定的算法把网格渲染成可以被看见的3D模型,二者缺一不可。
MeshFilter中的Mesh属性便是网格文件,这个文件除了使用预制的一些简单几何外,需要3D美术导出可以使用的3D模型文件,一般来说,打开一个模型文件,其中有符号的就是网格文件数据。
3D的Renderer——渲染器拥有两种类型,MeshRenderer——网格渲染器(左)和SkinnedMeshRenderer——蒙皮网格渲染器(右),其中MeshRenderer适用于静态的3D模型的渲染,而SkinnedMeshRenderer适用于具有蒙皮骨骼动画的3D模型的渲染。
它们大部分的属性都是相同的,其中最重要的是Materials——材质。
Mesh可以理解为构成3D模型的骨架,而Materials则是3D模型的皮肤,它描述了如何将Texture贴图在网格上展现。一个模型上可以拥有多个Materials。而一个Materials根据Shader——着色器的不同,也可以有不同数量的Texture——纹理。
Texture——纹理是一个可以看见的具体的二维图片(也有其他类型),简单的理解贴图是包裹在3D网格上的包装纸,我们能看见3D模型也是看见了视野之内的模型的贴图的样子。例如一个立方体把它的表面展开,展开的可视的部分就可以理解为贴图,而它真实的具有体积的部分就是网格。当然贴图不是随随便便贴在模型上的,纹理本身具有坐标信息,它与网格中的顶点坐标是一一对应的,它必须要有一套规则或者说方法让它正确地贴在模型的每一个三角面上,就如同我们必须按照正确的折叠顺序将展开的立方体贴图折回具有体积的立方体。而这一套规则包括Texture自身打包成一个单元的就是Material——材质。
Shader——着色器是Material——材质中最重要的部分,它将告诉硬件如何去渲染Texture,它的工作是将Texture捣鼓一通最终变成可以显示在显示器上的数据。你可以理解它是Material所持有的一种方法,而Texture是提供给Shader方法的参数,而最后的输出结果由Material传达给Renderer。当然,事实上模型的网格数据也是Shader方法的参数,它实际上是一个软硬件之间的接口,unity编辑界面中应用在物体上的方式更像是将Texture和Shader打包成Material提供给Renderer。而实际上一个模型可以被看到,Mesh、Texture、Shader、Material和Renderer缺一不可。
在了解unity对于3D模型的处理方式之后,便可以对大量散落的模型进行拆解合并的操作。
1.首先我们需要将纹理和材质从单个模型中剥离出来,并整合为一:
MeshRenderer[] meshRenderers = GetComponentsInChildren();
List materials = new List();
List textures = new List();
foreach(var renderer in meshRenderers) {
//合并贴图材质:
materials.Add(renderer.sharedMaterial);
Texture2D t2d = renderer.sharedMaterial.GetTexture("_MainTex") as Texture2D;
Texture2D nt2d = new Texture2D(t2d.width, t2d.height, TextureFormat.ARGB32, false);
nt2d.SetPixels(t2d.GetPixels(0, 0, t2d.width, t2d.height));
nt2d.Apply();
textures.Add(nt2d);
}
我们获取到根节点下全部的MeshRenderer,因为它具有全部的模型相关数据,之后将所有的sharedMaterial、Texture存储起来。其中Texture需要创建一个新的与原Texture具有相似参数的Texture来存储,不要对原文件进行改动。
2.确保根节点拥有MeshRenderer组件,因为我们的合并策略是将合并后的网格由根节点进行展示,而子节点将不会进行展示
MeshRenderer rootMeshRenderer = GetComponent();
if(rootMeshRenderer) {
} else {
rootMeshRenderer = this.gameObject.AddComponent();
}
3. 合并材质,用于合并的模型的材质参数最好相同,因为合并后的新模型需要使用相应材质参数以达到相同效果,如果有复数个材质就需要将它们都挂载到根节点的MeshRenderer上
//创建合并后的材质:
Material nMaterial = new Material(materials[0].shader);
nMaterial.CopyPropertiesFromMaterial(materials[0]);
rootMeshRenderer.material = nMaterial;
//创建合并后的贴图:
Texture2D ntex = new Texture2D(1024, 1024);
nMaterial.SetTexture("_MainTex", ntex);
4.合并网格和纹理打包
//合并网格:
MeshFilter[] meshFilters = GetComponentsInChildren();
List combines = new List();
//贴图打包 ,矩形的数组包含每个输入的纹理的UV坐标
Rect[] rects = ntex.PackTextures(textures.ToArray(), 10, 1024);
for (int i = 0; i < meshFilters.Length; i++) {
Rect rect = rects[i];
Mesh mesh = meshFilters[i].mesh;
Vector2[] newUVs = new Vector2[mesh.uv.Length];
for (int j = 0; j < mesh.uv.Length; j++) {
//uv是一个比值,u = 横向第u个像素/原始贴图的宽度 v = 竖向第v个像素/原始贴图的高度
//rect.x : 原贴图在合并后的贴图的 x 坐标, rect.y : 原贴图在合并后的贴图的 y 坐标
newUVs[j].x = mesh.uv[j].x * rect.width + rect.x;
newUVs[j].y = mesh.uv[j].y * rect.height + rect.y;
}
mesh.uv = newUVs;
//合并各个子网格:
CombineInstance combineInfo = new CombineInstance();
combineInfo.mesh = mesh;
Matrix4x4 combinMatrix = meshFilters[i].transform.localToWorldMatrix;
combinMatrix.m03 -= this.transform.position.x;
combinMatrix.m13 -= this.transform.position.y;
combinMatrix.m23 -= this.transform.position.z;
combineInfo.transform = combinMatrix;
combines.Add(combineInfo);
Destroy(meshFilters[i]);
Destroy(meshRenderers[i]);
}
CombineInstance是unity用于网格合并的内置类,用它来收集网格信息之后可以通过CombineMeshes()方法对网格进行合并。
这个部分的难点主要在于要先创建一张大的纹理来容纳原始纹理,随后需要保证新的纹理与生成的网格坐标仍然可以保持对应关系。PackTextures()方法可以将多个纹理打包到一个纹理图集中。
其使用了之前收集的textures。最后便是将每个子网格的UV数据对齐至贴图集合,mesh.uv代表这个网格的uv数据集,uv数据和顶点数据要对应,可以简单理解为顶点除了空间坐标XYZ以外还拥有一套UV坐标的坐标数据,这个数据就是用来与纹理的UV坐标相对应的。
在合并子网并对应UV之后,有一步重要的操作便是修正变换矩阵combineInfo.transform。因为子类对象的变换矩阵数据是根据父节点得到的,所以在合并后,新的模型应用变换矩阵时会认为其相对的是坐标原点,如果有需求根节点的位置不是坐标原点,就需要将每个子网格的变换矩阵减去根节点来保证位置的正确。
有关变换矩阵的计算与证明请参考:https://zhuanlan.zhihu.com/p/144323332
5.最后把合并的网格挂载到根节点即可,附上完整代码:
MeshFilter rootMeshFilter;
///
/// 合并贴图
///
public void Combining() {
MeshRenderer[] meshRenderers = GetComponentsInChildren();
List materials = new List();
List textures = new List();
foreach(var renderer in meshRenderers) {
//合并贴图材质:
materials.Add(renderer.sharedMaterial);
Texture2D t2d = renderer.sharedMaterial.GetTexture("_MainTex") as Texture2D;
Texture2D nt2d = new Texture2D(t2d.width, t2d.height, TextureFormat.ARGB32, false);
nt2d.SetPixels(t2d.GetPixels(0, 0, t2d.width, t2d.height));
nt2d.Apply();
textures.Add(nt2d);
}
MeshRenderer rootMeshRenderer = GetComponent();
if(rootMeshRenderer) {
} else {
rootMeshRenderer = this.gameObject.AddComponent();
}
//创建合并后的材质:
Material nMaterial = new Material(materials[0].shader);
nMaterial.CopyPropertiesFromMaterial(materials[0]);
rootMeshRenderer.material = nMaterial;
//创建合并后的贴图:
Texture2D ntex = new Texture2D(1024, 1024);
nMaterial.SetTexture("_MainTex", ntex);
//合并网格:
MeshFilter[] meshFilters = GetComponentsInChildren();
List combines = new List();
//贴图打包 ,矩形的数组包含每个输入的纹理的UV坐标
Rect[] rects = ntex.PackTextures(textures.ToArray(), 10, 1024);
for(int i = 0; i < meshFilters.Length; i++) {
Rect rect = rects[i];
Mesh mesh = meshFilters[i].mesh;
Vector2[] newUVs = new Vector2[mesh.uv.Length];
for(int j = 0; j < mesh.uv.Length; j++) {
//uv是一个比值,u = 横向第u个像素/原始贴图的宽度 v = 竖向第v个像素/原始贴图的高度
//rect.x : 原贴图在合并后的贴图的 x 坐标, rect.y : 原贴图在合并后的贴图的 y 坐标
newUVs[j].x = mesh.uv[j].x * rect.width + rect.x;
newUVs[j].y = mesh.uv[j].y * rect.height + rect.y;
}
mesh.uv = newUVs;
//合并各个子网格:
CombineInstance combineInfo = new CombineInstance();
combineInfo.mesh = mesh;
Matrix4x4 combinMatrix = meshFilters[i].transform.localToWorldMatrix;
combinMatrix.m03 -= this.transform.position.x;
combinMatrix.m13 -= this.transform.position.y;
combinMatrix.m23 -= this.transform.position.z;
combineInfo.transform = combinMatrix;
combines.Add(combineInfo);
Destroy(meshFilters[i]);
Destroy(meshRenderers[i]);
}
this.rootMeshFilter = GetComponent();
if(rootMeshFilter) {
} else {
rootMeshFilter = this.gameObject.AddComponent();
}
rootMeshFilter.mesh = new Mesh();
rootMeshFilter.mesh.CombineMeshes(combines.ToArray(), true, true);
this.gameObject.SetActive(true);
}
与静态网格合并的原理相似,也是拆离纹理、材质与网格来进行分别合并然后绑定UV。这里用了部分稍稍不同的方法,但是原理是相似的:
要注意的是因为蒙皮网格往往是和骨骼动画绑定的,所以需要把合并后的蒙皮网格重新绑定到骨骼上
SkinnedMeshRenderer[] orginalMeshRenderers;
///
/// 分组合并
///
public void CombinGroups() {
this.orginalMeshRenderers = GetComponentsInChildren();
var orGroups = this.orginalMeshRenderers.GroupBy(
skinRender => skinRender.sharedMaterial.name, skinRender => skinRender).ToList();
foreach(var materialMap in orGroups) {
//Debug.Log(materialMap.Key);
Transform rootTransform = new GameObject("Skin_" + materialMap.Key).transform;
rootTransform.localPosition = Vector3.zero;
rootTransform.parent = this.transform;
Combining(rootTransform, materialMap.ToArray());
}
}
public void Combining(Transform root, SkinnedMeshRenderer[] orginalSkins) {
List combineInstances = new List();
//List materials = new List();
Material material = orginalSkins[0].sharedMaterial;
List boneList = new List();
Transform[] transforms = this.GetComponentsInChildren();
//贴图信息:
List textures = new List();
int width = 0;
int height = 0;
int uvCount = 0;
List uvList = new List();
//收集皮肤网格、贴图、骨骼信息:
foreach(SkinnedMeshRenderer smr in orginalSkins) {
比较材质信息:
//if(material.name != smr.sharedMaterial.name) {
// continue;
//}
//皮肤网格合并信息:
for(int sub = 0; sub < smr.sharedMesh.subMeshCount; sub++) {
CombineInstance ci = new CombineInstance();
ci.mesh = smr.sharedMesh;
ci.subMeshIndex = sub;
combineInstances.Add(ci);
}
//记录贴图信息:
if(smr.material.mainTexture != null) {
Texture2D texture = smr.GetComponent().material.mainTexture as Texture2D;
textures.Add(texture);
width += texture.width;
height += texture.height;
uvList.Add(smr.sharedMesh.uv);
uvCount += smr.sharedMesh.uv.Length;
}
//记录骨骼信息:
Transform[] boneInfo = (from node in transforms
from bone in smr.bones
where node.name == bone.name
select node).ToArray();
boneList.AddRange(boneInfo);
smr.gameObject.SetActive(false);
}
//生成新的网格:
SkinnedMeshRenderer tempRenderer = root.gameObject.GetComponent();
if(!tempRenderer) {
tempRenderer = root.gameObject.AddComponent();
}
tempRenderer.sharedMesh = new Mesh();
//合并网格,刷新骨骼,附加材质:
tempRenderer.sharedMesh.CombineMeshes(combineInstances.ToArray(), true, false);
tempRenderer.bones = boneList.ToArray();
tempRenderer.material = material;
//创建贴图:
Texture2D skinnedMeshAtlas = new Texture2D(Get2Pow(width), Get2Pow(height));
Rect[] packingResult = skinnedMeshAtlas.PackTextures(textures.ToArray(), 0);
Vector2[] atlasUVs = new Vector2[uvCount];
//计算uv:
for(int i = 0; i < uvList.Count; i++) {
Rect rect = packingResult[i];
for(int j = 0; j < uvList[i].Length; j++) {
atlasUVs[j].x = Mathf.Lerp(rect.xMin, rect.xMax, uvList[i][j].x);
atlasUVs[j].y = Mathf.Lerp(rect.yMin, rect.yMax, uvList[i][j].y);
}
}
//设置贴图和uv:
tempRenderer.material.mainTexture = skinnedMeshAtlas;
tempRenderer.sharedMesh.uv = atlasUVs;
}
///
/// 获取最接近输入值的2的N次方的数,最大不会超过1024,例如输入320会得到512
///
public int Get2Pow(int into) {
int outo = 1;
for(int i = 0; i < 10; i++) {
outo *= 2;
if(outo > into) {
break;
}
}
return outo;
}
最后,需要合并的网格、纹理均要打开Read/Write,才能合并 。