需求
项目需要做个工具,用于合并模型贴图,以此可以省掉贴图尺寸为了补足2的次方而多出来的多余空白像素。
过程
- 平铺式编码,相当于一个 Demo,把几个没把握的主要问题解决,得出粗略结果
- 再和需求方确认细节,以此做相应调整优化
经验总结
- 做性能不敏感的编辑器工具,采用先收集数据,再统一处理的方式,会更加灵活,尽管重复遍历一次(一次收集,一次处理)
- 需求还是不能全信。本来确认过所有资源名字都是一一对应的,因此不需要收集引用的对应关系,直接按资源名字,改个后缀名就行。后来有了共用材质的模型,这样必然有一个材质和模型的名字不一样,好在比较好改。其实本来也是应该用引用来做比较通用,属于是“合理”偷懒了
Unity快速入门之四 - Unity模型动画相关_翕翕堂的博客-CSDN博客_unity 模型动画
主要问题
- fbx 文件无法修改资源引用(贴图、材质、网格)
把 fbx 实例化,再把这个实例另存为新预制,这时就可以修改模型的资源引用了 - 如何合并贴图?
借助 TexturePacker,项目中也一直是用这个工具来处理 UI 图集 - 每个网格的 UV 要根据合并的图重新设置对应坐标
TexturePacker 合并完成后会有一份配置,记录了小图在大图中的坐标,我们以此来修改 UV 坐标 - 当 fbx 重新导出时,不能改变原有的资源引用,否则每次都要全量热更
替换资源时,不要用 Unity 的函数,它的替换会连同 meta 一起删除再重新创建,这样资源 id 就会发生变化,引用也就变了。所以直接用系统的方法替换文件,不改变 meta
FBX 文件是什么
AutoDesk 提供的模型文件格式,以二进制形式存储。包含各种模型信息,比如模型面数、三角形数、顶点坐标、骨骼动画信息、模型版本号信息等等。
深入理解加载FBX模型文件
Unity 中组成模型的部件有哪些?
MeshFilter + MeshRenderer(不带蒙皮),MeshFilter负责网格绘制(挂载 mesh),MeshRenderer负责材质表现(挂载材质)
SkinnedMeshRenderer(带蒙皮),包含了网格和材质
从图形学认识Unity中的Mesh
分离 FBX
因为需要对网格、贴图和材质进行自定义,所以 fbx 源文件不能直接使用,需要把资源都拷贝一份,再对拷贝出来的资源进行修改。
避免篇幅太长,代码删除了一些检查性判断。
创建 fbx 预制
// 创建单个fbx预制
public static void CreateOneFbxPrefab(Object makingFbxObj)
{
string makingFbxPath = AssetDatabase.GetAssetPath(makingFbxObj);
string makingFbxDir = Path.GetDirectoryName(makingFbxPath);
string newFbxPrefabDir = makingFbxDir.ReplacePath("art/making", "Data");
// 创建目录
if (!Directory.Exists(newFbxPrefabDir))
{
FileHelper.CreateDirectory(newFbxPrefabDir);
AssetDatabase.ImportAsset(newFbxPrefabDir);
}
// 创建 fbx 预制
string fbxName = makingFbxObj.name;
string newPrefabPath = newFbxPrefabDir + "/" + fbxName + ".prefab";
GameObject newGo = AssetDatabase.LoadAssetAtPath<GameObject>(newPrefabPath);
if(newGo == null)
{
newGo = new GameObject(fbxName);
// fbx 作为子节点
GameObject newFbx = Object.Instantiate(makingFbxObj) as GameObject;
newFbx.name = fbxName;
newFbx.transform.SetParent(newGo.transform);
newFbx.transform.localPosition = Vector3.zero;
newFbx.transform.eulerAngles = new Vector3(0, 0, 0);
PrefabUtility.SaveAsPrefabAssetAndConnect(newGo, newPrefabPath, InteractionMode.AutomatedAction);
GameObject.DestroyImmediate(newGo, true);
}
else
{
// Debug.LogFormat("预制已经存在,不另外创建 {0}", makingFbxPath);
}
}
创建网格、贴图和材质
public static void SplitOneFbxRes(Object makingFbxObj)
{
string makingFbxPath = AssetDatabase.GetAssetPath(makingFbxObj);
string makingFbxDir = Path.GetDirectoryName(makingFbxPath);
string newFbxResDir = makingFbxDir.ReplacePath("making/", "");
// 创建目录
if (!Directory.Exists(newFbxResDir))
{
FileHelper.CreateDirectory(newFbxResDir);
AssetDatabase.ImportAsset(newFbxResDir);
}
// 重新导入一下fbx,防止引用没有更新
AssetDatabase.ImportAsset(makingFbxPath);
// 把贴图、材质、网格拷贝到新目录
GameObject makingFbx = AssetDatabase.LoadAssetAtPath<GameObject>(makingFbxPath);
MeshRenderer[] makingFbxMrs = makingFbx.GetComponentsInChildren<MeshRenderer>();
foreach (var makingMr in makingFbxMrs)
{
Material makingMat = makingMr.sharedMaterial;
Texture makingTex = makingMat.mainTexture;
// 拷贝贴图
string makingTexPath = AssetDatabase.GetAssetPath(makingTex);
string newTexPath = newFbxResDir + "/" + Path.GetFileName(makingTexPath);
FileUtil.ReplaceFile(makingTexPath, newTexPath);
// 拷贝材质
string newMatPath = newFbxResDir + "/" + makingMat.name + ".mat";
Material newMat = AssetDatabase.LoadAssetAtPath<Material>(newMatPath);
if(newMat == null)
{
newMat = Object.Instantiate(makingMat) as Material;
AssetDatabase.CreateAsset(newMat, newMatPath);
// 只有新材质才需要更新贴图引用
AssetDatabase.ImportAsset(newTexPath);
Texture newTex = AssetDatabase.LoadAssetAtPath<Texture>(newTexPath);
newMat.mainTexture = newTex;
}
else
{
// 如果已经存在材质,则不处理材质(贴图在上一步直接就替换了)
}
MeshFilter makingMf = makingMr.GetComponent<MeshFilter>();
Mesh makingMesh = makingMf.sharedMesh;
// 拷贝网格
string makingMeshPath = AssetDatabase.GetAssetPath(makingMesh);
string newMeshPath = newFbxResDir + "/" + makingMesh.name + ".mesh";
Mesh newMesh = AssetDatabase.LoadAssetAtPath<Mesh>(newMeshPath);
if(newMesh == null)
{
newMesh = Object.Instantiate(makingMesh) as Mesh;
newMesh.SetNormals(new List<Vector3>(0));
newMesh.SetTangents(new List<Vector4>(0));
// 这个方法会先删除旧资源,包括 meta,再创建,会导致原来的引用丢失
AssetDatabase.CreateAsset(newMesh, newMeshPath);
Debug.LogFormat("创建网格:{0}", newMeshPath);
}
else
{
newMesh.indexFormat = makingMesh.indexFormat;
newMesh.SetTriangles(makingMesh.triangles, 0);
newMesh.SetVertices(makingMesh.vertices);
newMesh.SetUVs(0, makingMesh.uv);
newMesh.SetUVs(1, makingMesh.uv2);
newMesh.SetIndices(makingMesh.GetIndices(0), makingMesh.GetTopology(0), 0);
newMesh.SetColors(makingMesh.colors);
newMesh.SetNormals(new List<Vector3>(0));
newMesh.SetTangents(new List<Vector4>(0));
Debug.LogFormat("更新网格:{0}", newMeshPath);
}
}
}
贴图合并
// 合并纹理
public static void CombineFbxDirRes(Object makingFbxDirObj)
{
string makingFbxDir = AssetDatabase.GetAssetPath(makingFbxDirObj);
string newFbxResDir = makingFbxDir.ReplacePath("making/", "");
// 创建目录
if (!Directory.Exists(newFbxResDir))
{
FileHelper.CreateDirectory(newFbxResDir);
AssetDatabase.ImportAsset(newFbxResDir);
}
// 合并贴图
DirectoryInfo resDirInfo = new DirectoryInfo(makingFbxDir);
string resDirName = resDirInfo.Name;
string tpName = resDirName;
string tpDir = "Assets/TP/" + tpName;
// 借助 Texturepacker 合并贴图,这里自己需要自己封装方法处理
AtlasEditorHelper.BuildMakeAtlas(tpName, makingFbxDir, tpDir, AtlasEditorHelper.TrimMode.Trim);
// 新贴图放到art/scene
string tpTexPath = string.Format("{0}/{1}.png", tpDir, tpName);
string newTexPath = string.Format("{0}/{1}.png", newFbxResDir, tpName);
FileUtil.ReplaceFile(tpTexPath, newTexPath);
AssetDatabase.ImportAsset(newTexPath);
// 通过 TexturePacker 的输出文件获得新纹理信息
Texture newTex = AssetDatabase.LoadAssetAtPath<Texture>(newTexPath);
string tpTxtPath = string.Format("{0}/{1}.txt", tpDir, tpName);
string tpText = FileHelper.ReadTextFromFile(tpTxtPath);
List<TexturePacker.PackedFrame> frames = TexturePacker.ProcessToFrames(tpText);
// 新图大小
System.Collections.Hashtable hashtable = tpText.hashtableFromJson();
TexturePacker.MetaData metaData = new TexturePacker.MetaData((System.Collections.Hashtable)hashtable["meta"]);
Vector2 totalSize = metaData.size;
// 拷贝所有mesh到art/scene
string[] makingMeshGuids = AssetDatabase.FindAssets("t:Mesh", new string[] { makingFbxDir });
Dictionary<string, Mesh> meshName2Mesh = new Dictionary<string, Mesh>();
foreach (var makingMeshGuid in makingMeshGuids)
{
string makingMeshPath = AssetDatabase.GUIDToAssetPath(makingMeshGuid);
Mesh makingMesh = AssetDatabase.LoadAssetAtPath<Mesh>(makingMeshPath);
string newMeshPath = newFbxResDir + "/" + makingMesh.name + ".mesh";
Mesh newMesh = Object.Instantiate(makingMesh) as Mesh;
newMesh.SetTangents(new List<Vector4>(0));
newMesh.SetNormals(new List<Vector3>(0));
AssetDatabase.CreateAsset(newMesh, newMeshPath);
meshName2Mesh[makingMesh.name] = newMesh;
}
// 收集mesh和纹理的对应关系(多个mesh共用一个纹理)
Dictionary<string, string> meshName2TexName = new Dictionary<string, string>();
string[] makingFbxGuids = AssetDatabase.FindAssets("t:Model", new string[] { makingFbxDir });
foreach (var makingFbxGuid in makingFbxGuids)
{
string makingFbxPath = AssetDatabase.GUIDToAssetPath(makingFbxGuid);
GameObject fbx = AssetDatabase.LoadAssetAtPath<GameObject>(makingFbxPath);
// 取 mesh
MeshFilter mf = fbx.GetComponent<MeshFilter>();
MeshRenderer mr = fbx.GetComponent<MeshRenderer>();
meshName2TexName[mf.sharedMesh.name] = mr.sharedMaterial.mainTexture.name;
}
// 需要遍历 mesh,再根据纹理名字找到纹理数据,所以这里映射一下
Dictionary<string, TexturePacker.PackedFrame> texName2PackFrame = new Dictionary<string, TexturePacker.PackedFrame>();
foreach (TexturePacker.PackedFrame pm in frames)
{
texName2PackFrame[Path.GetFileNameWithoutExtension(pm.name)] = pm;
}
// 修改mesh的UV
foreach (KeyValuePair<string, string> p in meshName2TexName)
{
string meshName = p.Key;
string texName = p.Value;
TexturePacker.PackedFrame pm = texName2PackFrame[texName];
// tp左上角为原点,贴图uv左下角为原点
float beginX = pm.frame.x - pm.spriteSourceSize.x;
float trimYTop = pm.spriteSourceSize.y;
// float trimYBottom = fm.sourceSize.y - trimYTop - fm.spriteSourceSize.height;
// float beginY = totalSize.y - (fm.frame.y + fm.frame.height + trimYBottom);
float beginY = totalSize.y - (pm.frame.y + pm.sourceSize.y - trimYTop);
Mesh newMesh;
if(meshName2Mesh.TryGetValue(meshName, out newMesh))
{
Vector2[] oldUVs = newMesh.uv;
Vector2[] newUVs = new Vector2[oldUVs.Length];
for (int i = 0; i < oldUVs.Length; i++)
{
float oldUvX = oldUVs[i].x;
float oldUvY = oldUVs[i].y;
float newX = (beginX + (oldUvX * pm.sourceSize.x)) / totalSize.x;
float newY = (beginY + (oldUvY * pm.sourceSize.y)) / totalSize.y;
newUVs[i] = new Vector2(newX, newY);
}
newMesh.uv = newUVs;
EditorUtility.SetDirty(newMesh);
// Debug.LogFormat("修改mesh的uv:{0}", newMesh.name);
}
else
{
Debug.LogErrorFormat("图片名没有对应的网格:{0}", pm.name);
continue;
}
}
// 创建新合并材质
string newMatPath = newFbxResDir + "/" + tpName + ".mat";
Material combinedMat = AssetDatabase.LoadAssetAtPath<Material>(newMatPath);
if(combinedMat == null)
{
Shader shader = AssetDatabase.LoadAssetAtPath<Shader>("Assets/Shader/Builtin/Unlit-Normal.shader");
combinedMat = new Material(shader);
combinedMat.name = tpName;
AssetDatabase.CreateAsset(combinedMat, newMatPath);
}
combinedMat.mainTexture = newTex;
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
替换合并的贴图、材质和网格
把原来拷贝出来的模型贴图、材质和网格,替换成合并后的。
// 替换一个模型预制的材质和网格(已有资源路径一致则不替换)
public static void ReplaceOneMaterialAndMesh(Object fbxPrefabObj, ResCollection resCollection = null)
{
GameObject fbxPrefab = fbxPrefabObj as GameObject;
string fbxPrefabPath = AssetDatabase.GetAssetPath(fbxPrefabObj);
if(resCollection == null)
{
// Assets/Data/scene/home/furniture/50128.prefab
// Assets/Art/scene/home/furniture
string fbxPrefabDir = Path.GetDirectoryName(fbxPrefabPath);
string fbxResDir = fbxPrefabDir.ReplacePath("Data", "Art");
resCollection = new ResCollection();
resCollection.Collect(new string[]{fbxResDir});
}
bool isChanged = false;
// 检查网格
MeshFilter[] mfs = fbxPrefab.GetComponentsInChildren<MeshFilter>();
Dictionary<string, string> meshName2MeshPath = resCollection.meshName2MeshPath;
foreach (MeshFilter mf in mfs)
{
Mesh sharedMesh = mf.sharedMesh;
string meshName = sharedMesh.name;
string meshPath = AssetDatabase.GetAssetPath(sharedMesh);
string collectedMeshPath;
if(meshName2MeshPath.TryGetValue(meshName, out collectedMeshPath))
{
// mesh存在,但资源不是在指定目录,则更换为指定目录的
if(meshPath.StandardPath() != collectedMeshPath.StandardPath())
{
mf.sharedMesh = AssetDatabase.LoadAssetAtPath<Mesh>(collectedMeshPath);
Debug.LogFormat("更新网格:{0}\n节点:{1}\n旧路径:{2}\n新路径:{3}", fbxPrefab.name, mf.name, meshPath, collectedMeshPath);
isChanged = true;
}
}
else
{
// Debug.LogFormat("没有同名网格:{0}\n节点:{1}\n旧路径:{2}", fbxPrefab.name, mf.name, meshPath);
}
}
// 检查材质
MeshRenderer[] mrs = fbxPrefab.GetComponentsInChildren<MeshRenderer>();
Dictionary<string, string> matName2MatPath = resCollection.matName2MatPath;
foreach (var mr in mrs)
{
Material sharedMat = mr.sharedMaterial;
if(sharedMat == null)
{
Debug.LogErrorFormat("MeshRenderer上没有material:{0}\n {1}", fbxPrefab.name, mr.name);
continue;
}
string matName = sharedMat.name;
string matPath = AssetDatabase.GetAssetPath(sharedMat);
string collectedMatPath;
if(matName2MatPath.TryGetValue(matName, out collectedMatPath))
{
// material存在,但资源不是在指定目录,则更换为指定目录的
if(matPath != collectedMatPath)
{
mr.sharedMaterial = AssetDatabase.LoadAssetAtPath<Material>(collectedMatPath);
Debug.LogFormat("替换材质:{0}\n节点:{1}\n旧路径:{2}\n新路径:{3}", fbxPrefab.name, mr.name, matPath, collectedMatPath);
isChanged = true;
}
}
else
{
// 没有同名材质
// 有合并材质,且当且材质名和预制名一样,则替换为合并材质(第一次创建的时候)
if(resCollection.combinedMat != null && fbxPrefab.name == matName)
{
mr.sharedMaterial = resCollection.combinedMat;
Debug.LogFormat("替换合并材质:{0}\n节点:{1}\n旧路径:{2}\n新路径:{3}", fbxPrefab.name, mr.name, matPath, collectedMatPath);
isChanged = true;
}
else
{
// Debug.LogFormat("没有同名材质:{0}\n节点:{1}\n旧路径:{2}", fbxPrefab.name, mr.name, matPath);
}
}
}
if(isChanged)
{
GameObject fbxPrefabGo = GameObject.Instantiate(fbxPrefab);
PrefabUtility.SaveAsPrefabAssetAndConnect(fbxPrefabGo, fbxPrefabPath, InteractionMode.AutomatedAction);
GameObject.DestroyImmediate(fbxPrefabGo, true);
}
}
其他参考
【Unity3D】 合并mesh那些事 CombineMeshes(二)
骨骼动画程序原理介绍
Skinned Mesh原理解析和一个最简单的实现示例
Unity Shader:Unity网格(1)---顶点,三角形朝向,法线,uv,以及双面渲染三角形