简介:本资源是一套基于TriLib 2.1.7的Unity运行时外部模型动态加载解决方案,面向Unity中级开发者及三维应用开发工程师,解决游戏、仿真、数字孪生等场景中需在程序运行时灵活加载本地.fbx/.obj模型的核心需求。资源包共622个文件,含89个C#脚本(实现加载逻辑与映射器)、18个DLL(TriLib核心库及Draco解码支持)、15个Unity场景(含AssetViewer主测试场景及多组示例)、20个PNG纹理与13个Shader材质,整体压缩后仅13.31MB,轻量易集成。已有3021人学习下载,实测兼容Unity 2019.4.9与2021.3.16等主流LTS版本。用户可直接打开AssetViewer.unity场景选择外部模型实时加载,同时通过预置的ByNameRootBoneMapper、StandardMaterialMapper、HDRPMaterialMapper等资产配置,快速理解模型骨骼绑定、材质映射与光照适配等关键流程,具备完整工程结构与即用型API封装。 做工具类Unity项目,基本都会遇到这种需求:程序运行期间,用户从磁盘拖进来一个模型文件,程序要立刻把模型显示在场景里。这个需求看上去不复杂,但真要落地,坑比想象中多。尤其是当输入文件是外部下载的.fbx或.obj格式时,你会发现Unity编辑器里的“拖进Project窗口自动导入”这套流程完全用不上,因为运行时没有编辑器,没有AssetImporter,一切都要靠代码自己搞定。
这篇文章就围绕“Unity运行时动态加载外部fbx/obj模型文件”这个主题,从方案选型、格式原理、代码实现、坑点排查一条线捋下来。我尽量把实际项目中踩过的坑和验证过的做法写清楚,不是纸上谈兵,适合正在做模型预览工具、数字孪生演示、BIM展示、或者需要热加载模型做内容更新的Unity开发者参考。
1. 方案选型:为什么不直接用AssetBundle
很多刚接触这个需求的同学第一反应是用AssetBundle。这个思路在“内置资源更新”场景下完全没问题,但一旦你的模型是“用户运行时给进来的外部文件”,AssetBundle就直接被排除掉了。原因很简单:AssetBundle只能加载已经由Unity编辑器打包过的.ab资源,外部裸奔的fbx/obj文件根本没有对应的AB包,你不可能让用户在自己电脑上跑一遍Unity编辑器把模型打成bundle再扔给你的程序。
那有没有可能写个工具把任意fbx/obj转换成AB包?技术上可行,但工程上非常不划算。转换AB包依赖UnityEditor API,这要求你的产品里内嵌一个完整的编辑器环境,体积和复杂度直接爆炸。而且用户随时可能拿到一个新模型,每次都要走“导入→打包→加载”链路,实时性完全谈不上。
所以运行时加载外部fbx/obj,本质上只有两条路线:
- 纯C#解析模型文件格式,把顶点、法线、UV、三角形索引从文件里读出来,构建Unity的Mesh对象。
- 借助外部转换服务,先运行一个独立的转换工具把fbx/obj转成Unity友好的中间格式(比如
.bytes二进制或JSON),运行时直接加载这个中间格式。
两条路线我都实践过。如果只是加载obj,纯C#解析完全够用,obj格式极其简单,文本结构清晰,自己写解析器也就几百行代码。但fbx就麻烦多了,fbx格式分ASCII和二进制两种,二进制版本又非常多,目前没有官方C# SDK,想纯C#全覆盖非常吃力。我的建议是:如果产品要长期支持fbx,优先考虑“后端预转换”策略,把解析工作放到编辑器工具或独立转换器里完成,运行时只负责加载转换后的数据。
对比一下:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 纯C#解析obj | 简单、零依赖、代码可控 | 不支持复杂材质/动画/多格式 | 快速原型、简单几何体展示 |
| 纯C#解析fbx | 能处理原始fbx | 格式版本多、二进制解析繁琐、开发成本高 | 深度定制、离线工具链 |
| 后端预转换 | 稳定可控、运行时轻量 | 需要额外写转换工具 | 产品级工具、数字孪生/BIM |
| AssetBundle | 官方支持、性能好 | 仅限编辑器内导入,运行时外部文件不可用 | 游戏内容更新、内置模型 |
2. 格式原理解读:obj和fbx到底在存什么
要正确写解析器,必须先搞懂模型文件内部存的是什么。很多Unity新手一上来就搜“加载fbx的API”,结果发现Unity官方根本没有这种API——因为这本质上不是“加载”,而是“解析后重建网格”。
2.1 obj其实是一个纯文本索引表
obj文件是最直观的文本格式,每一行一个数据项。核心就几种:
v x y z:顶点坐标,一行一个顶点vt u v:UV坐标vn x y z:法线f v1/vt1/vn1 v2/vt2/vn2 ...:面(三角面或四边形),斜杠分隔的是顶点索引/UV索引/法线索引
关键在于索引的含义。obj的索引是1起始,不是C#的0起始,而且它允许不同的属性各自独立索引。比如f 1/1/1 2/2/2 3/3/3三个顶点正好一一对应,但有时候会出现f 1/1/1 2/2/2 3/3/2这种——顶点索引和UV索引不对应。这种情况很常见,因为DCC软件导出时,同一个顶点位置可能对应多个UV缝(比如UV拆边处)。
所以在构建Unity的Mesh时,不能简单地把四个数组往Mesh里一塞就完事。Unity的Mesh要求顶点、UV、法线数组长度一致,并且通过顶点索引数组来定义三角形。这意味着你必须做一次“顶点去重+重映射”:把(顶点索引、UV索引、法线索引)的三元组合并成一个新的唯一顶点,重新生成索引数组。
这也是obj解析最容易出错的地方。我见过很多初学者直接把顶点数组按原样塞给Unity,结果物体显示出来是花的、黑的或者撕裂的,根因就是索引没有重映射。
2.2 fbx是分层的二进制节点树
fbx比obj复杂一个数量级。fbx 7.x之后默认是二进制格式,文件本质上是一个节点树,每个节点有记录头、属性和子节点。解析时你需要遍历这个树,找到Geometry节点下的Vertices数组(顶点坐标)、PolygonVertexIndex数组(多边形顶点索引)、LayerElementNormal(法线)和LayerElementUV(UV)。
PolygonVertexIndex数组的设计比较特殊:它以“多边形”为单位存储,每个多边形的顶点索引写入数组,如果这个多边形是最后一个面,索引值会用负数表示——~index(按位取反)表示这个多边形结束。比如一个四边形,存储为0 1 2 ~3,解析时遇到负数就要知道这个面收尾了,把负数还原成~idx & 0x7fffffff可以得到真实索引。
UV和法线在fbx里是“按控制点索引”还是“按多边形顶点索引”存储,取决于MappingInformationType字段。不同DCC软件导出的结果可能不一样,如果直接用原始索引拼接,大概率对不上。正确的做法是先读取LayerElementNormal/LayerElementUV的Mapping和Reference信息,再决定如何把UV/法线映射到顶点上。这一步的复杂程度,就是纯C#解析fbx最大的成本所在。
2.3 为什么Unity没有内置运行时加载fbx的接口
Unity引擎本身有fbx解析能力,但那是给编辑器用的,挂在UnityEditor命名空间下。你在运行时去查API,找不到可用的加载fbx方法,只能拿到Mesh.LoadMesh这类偏底层的东西,它仍然要求你先有Mesh数据。编辑器构建Mesh的方法是:AssetImporter导入fbx → 生成Mesh对象 → 打包或保存。
运行时没有AssetDatabase,自然没法走这条链路。所以市面上的运行时模型加载插件(比如Trilib、UnityRuntimeModelViewer)本质上都是“自带解析器”,在C#里重写了fbx/obj的解析逻辑。这也意味着,运行时加载外部模型的性能和稳定性,完全取决于解析器本身写得好不好。
3. 实操过程:从零搭建运行时模型加载器
这块内容比较多,我按“先obj后fbx转换器”的顺序,把核心代码和流程拆开讲。产品最终选择的是“纯C#解析obj + 编辑器工具预转换fbx”的组合,兼顾了功能覆盖和运行时的可控性。
3.1 手写obj解析器的核心步骤
第一步先把文件读进来,按行切分。文件可能很大,所以用StreamReader.ReadLine逐行处理,不要一次性File.ReadAllText把整个文件读进字符串,容易出现大文件卡顿和内存翻倍的问题。
解析的核心是定义中间结构体。我一般这样写:
public class ObjMeshData { public List<Vector3> positions = new List<Vector3>(); public List<Vector2> uvs = new List<Vector2>(); public List<Vector3> normals = new List<Vector3>(); public List<Vector3> finalVerts = new List<Vector3>(); public List<Vector2> finalUvs = new List<Vector2>(); public List<Vector3> finalNormals = new List<Vector3>(); public List<int> triangles = new List<int>(); public Dictionary<Vector3Int, int> vertexMap = new Dictionary<Vector3Int, int>(); }vertexMap的键是(positionIndex, uvIndex, normalIndex)的三元组。每读到一个面,对它的每个顶点做查表:如果这个三元组已经在字典里,直接用对应的最终顶点索引;如果不在,就新建一个最终顶点,往finalVerts/finalUvs/finalNormals里添加数据,并把这个新索引记录到字典里。
关键代码逻辑大概长这样:
// 解析 f 行 string[] parts = line.Split(' '); List<int> faceIndices = new List<int>(); foreach (string part in parts.Skip(1)) // 跳过 'f' { string[] indices = part.Split('/'); int posIdx = int.Parse(indices[0]) - 1; // obj 索引从1开始 int uvIdx = indices.Length > 1 && indices[1].Length > 0 ? int.Parse(indices[1]) - 1 : -1; int normalIdx = indices.Length > 2 && indices[2].Length > 0 ? int.Parse(indices[2]) - 1 : -1; Vector3Int key = new Vector3Int(posIdx, uvIdx, normalIdx); if (vertexMap.TryGetValue(key, out int existingIdx)) { faceIndices.Add(existingIdx); } else { int newIdx = finalVerts.Count; finalVerts.Add(positions[posIdx]); finalUvs.Add(uvIdx >= 0 ? uvs[uvIdx] : Vector2.zero); finalNormals.Add(normalIdx >= 0 ? normals[normalIdx] : Vector3.up); vertexMap[key] = newIdx; faceIndices.Add(newIdx); } } // 三角形化 for (int i = 1; i < faceIndices.Count - 1; i++) { triangles.Add(faceIndices[0]); triangles.Add(faceIndices[i]); triangles.Add(faceIndices[i + 1]); }做完这一步,Mesh数据就齐了,接下来把它转成Unity的Mesh对象:
Mesh mesh = new Mesh(); mesh.name = "RuntimeLoadedMesh"; mesh.SetVertices(finalVerts); mesh.SetUVs(0, finalUvs); mesh.SetNormals(finalNormals); mesh.SetTriangles(triangles, 0); mesh.RecalculateBounds();注意RecalculateBounds很有用,如果不调用,网格在场景里可能显示“不存在”或者裁剪异常。如果解析出来的法线缺失严重,可以在构建后调用mesh.RecalculateNormals()自动重建法线,但效果取决于模型本身,复杂凹面模型自动法线计算可能有瑕疵。
提示:obj文件里可能存在
o(对象名)、g(组名)、s(平滑组)、usemtl(材质引用)这些行。如果只做几何展示,这些可以忽略;但如果要做材质映射,usemtl必须记录,再配合.mtl文件解析材质。
3.2 fbx转换器:编辑器工具里预处理
对于fbx,我采用了“编辑器预转换”的方案,写了一个Unity Editor窗口工具。用户把fbx放在指定目录,工具批量扫描,读取fbx的网格和材质信息,序列化成自定义的二进制中间格式,运行时直接加载这个中间文件。
[MenuItem("Tools/Convert FBX to Runtime Format")] public static void ConvertAllFbx() { string[] guids = AssetDatabase.FindAssets("t:Model", new[] { "Assets/ImportModels" }); foreach (string guid in guids) { string path = AssetDatabase.GUIDToAssetPath(guid); GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path); MeshFilter[] filters = prefab.GetComponentsInChildren<MeshFilter>(true); // 遍历每个MeshFilter,收集Mesh数据 foreach (MeshFilter filter in filters) { Mesh mesh = filter.sharedMesh; SaveRuntimeMesh(path, mesh); } } }这里用Unity编辑器自身的fbx导入能力(通过AssetDatabase),相当于“让Unity做一次彻底的fbx解析”,然后把解析结果用BinaryWriter写成自定义格式。运行时加载时,BinaryReader读回顶点、三角、UV、法线、包围盒等数据,直接构造Mesh。
自定义二进制格式的布局我习惯这样设计:
文件头(魔数+版本号) 网格数量 每个网格: 名称 顶点数 [全部顶点坐标: float * 3 * count] 法线数(通常等于顶点数) [全部法线: float * 3 * count] UV坐标数 [全部UV: float * 2 * count] 三角形索引数 [全部索引: int * count] 子网格数量(如果有多个材质)运行时加载的代码就这么简单:
BinaryReader reader = new BinaryReader(File.OpenRead(path)); Mesh mesh = new Mesh(); // 读取顶点、法线、UV、三角索引... reader.Read(vertBuffer, 0, vertBuffer.Length); mesh.SetVertices(vertBuffer.ToList()); reader.Read(indexBuffer, 0, indexBuffer.Length); mesh.SetTriangles(indexBuffer.ToList(), 0);这套方案的好处是运行时完全不依赖UnityEditor,也不会被fbx格式版本变化影响。缺点是fbx文件更新后,需要重新跑一次转换。不过在我做过的BIM展示和数字孪生项目中,模型更新频率并不高,预转换完全可以接受。
3.3 材质的处理:fbx里的材质不会自己变成Unity材质
模型文件里的材质信息,在Unity里是没法直接用的。obj的.mtl最多提供颜色、透明度、贴图路径这些参数,fbx里还能包含PBR参数(金属度、粗糙度、自发光等),但这些都需要你自己去映射成Unity的Material对象。
最简单的做法是针对每个材质,创建Unity的Standard或URP/Lit材质,把从文件里读到的漫反射颜色、贴图、金属度、粗糙度设置上去。如果模型自带贴图文件,需要额外处理贴图加载——因为Unity的AssetBundle之外,运行时加载Texture2D只能靠ImageConversion.LoadImage(byte[])从PNG/JPG字节流构建。我做过的最“重”的方案是,转换器工具把贴图也一起二进制成.bytes文件,运行时通过Texture2D.LoadImage还原。
Texture2D tex = new Texture2D(2, 2); if (ImageConversion.LoadImage(tex, File.ReadAllBytes(texturePath))) { mat.mainTexture = tex; } mat.color = diffuseColor; mat.SetFloat("_Metallic", metallic); mat.SetFloat("_Glossiness", smoothness);如果不想管材质,还有个偷懒的办法:全部用默认材质new Material(Shader.Find("Standard")),只设置一个颜色。模型能显示,但效果会比较“裸奔”,尤其是有透明贴图和自发光的时候,会非常难看。如果你的产品对视觉有一定要求,材质映射这步不能省。
3.4 把模型挂到场景里:坐标轴和缩放细节
模型解析完只是生成了Mesh对象,还需要创建一个GameObject挂MeshFilter和MeshRenderer才能显示。这个环节有几个容易踩的坑。
第一是坐标系。fbx/obj的全局约定一般是Y轴向上,Unity也是Y轴向上,理论上不用处理。但很多建筑类、工业类模型出自3ds Max、Revit这类软件,习惯Z轴向上。如果加载进来发现模型是“躺倒”的,就需要在根节点上加一个旋转修正:
obj.transform.rotation = Quaternion.Euler(90f, 0, 0);第二是缩放。不同DCC软件的单位不一样——3ds Max默认单位是英寸,Blender是米,Revit是毫米。导出的模型如果尺寸单位不对,放进Unity里要么巨大要么微小。建议读取文件时如果有单位信息就转换,没有的话至少提供缩放因子参数,方便用户手动调整。
第三是双面显示。很多单面建模的模型(尤其建筑白模)没有背面法线,从反面看是透明的。Unity的Mesh是不存在“双面”概念的,要么你在解析时把三角形索引反转复制一份生成双面网格,要么在Shader里关闭背面剔除。前者会增加顶点和面数,后者省事但可能影响光照表现。
4. 异步加载与内存管理
外部模型文件大小差异极大,一个几MB的obj可能就包含几十万个顶点,解析和构建Mesh的过程如果放在主线程同步执行,UI直接卡死几秒到几十秒。这在交互式工具里是致命的。所以从第一天起,我就把加载流程设计成异步协程或Task。
4.1 协程还是Task
Unity里两个选项都可行,我倾向于用协程做文件解析,用JobSystem做Mesh构建。这么分工的原因很实际:文件解析是纯IO和字符串操作,天然适合丢到后台线程;而Mesh的SetVertices/SetTriangles这类API如果在子线程调用,Unity会直接报错(访问主线程受限的引擎对象)。协程配合yield return null分帧处理,可以在不卡主线程的前提下完成解析和Mesh构建。
一个典型的分帧协程流程:
IEnumerator LoadModelAsync(string path) { // 步骤1:后台线程读取文件并解析原始数据 bool parseDone = false; ObjMeshData data = null; ThreadPool.QueueUserWorkItem(_ => { data = ObjParser.Parse(path); parseDone = true; }); while (!parseDone) yield return null; // 步骤2:主线程构建Mesh Mesh mesh = new Mesh(); mesh.SetVertices(data.finalVerts); mesh.SetTriangles(data.triangles, 0); // ... 其他赋值 // 步骤3:创建GameObject显示 GameObject go = new GameObject("LoadedModel"); go.AddComponent<MeshFilter>().mesh = mesh; go.AddComponent<MeshRenderer>().material = defaultMat; }真正耗时的解析被丢到线程池,主线程只是每帧检查一下是否完成,开销很小。Debug.Log里看性能的话,加载一个2万面的obj,原来同步要800ms,改成异步后主线程几乎无感知,体感就是“点了确定,模型转个圈就出来了”。
4.2 缓存和重复加载
同一个模型文件,用户可能加载了一次又一次。与其每次重新解析、重新构建Mesh,不如做个简单的缓存字典。以文件路径+最后修改时间为key,缓存内容和构建好的Mesh对象。这样第二次加载相同文件时,命中缓存直接秒开。
public class ModelCache { private static Dictionary<string, Mesh> _meshCache = new Dictionary<string, Mesh>(); public static Mesh GetMesh(string path, string fileHash) { string key = $"{path}_{fileHash}"; if (_meshCache.TryGetValue(key, out Mesh cached)) return cached; return null; } public static void CacheMesh(string path, string fileHash, Mesh mesh) { string key = $"{path}_{fileHash}"; _meshCache[key] = mesh; } }这个缓存的粒度可以再细分——如果只缓存最终Mesh,材质变更时缓存就失效了;更好的做法是缓存解析后的中间数据(顶点等),这样换材质时不用重新解析,Mesh对象重建一下即可。
4.3 卸载和释放资源
运行时加载的Mesh和Texture都是普通C#对象,受Unity的Resources.UnloadUnusedAssets和Destroy管理。这里最大的坑是:如果你频繁加载新模型并Destroy旧模型,而不调用Resources.UnloadUnusedAssets,内存占用会一直往上走,因为Mesh对象(尤其是顶点缓冲)本身可能分配在非托管内存里,普通GC不负责回收。
我的做法是:切换模型前,先记录旧模型的Mesh和Material引用,加载并显示新模型后,再对旧资源调用Destroy(oldMesh)和Destroy(oldMat)。如果确定某个模型以后不再使用,可以主动调用Resources.UnloadUnusedAssets()触发一次完整回收,但注意这个方法会异步执行,不能立刻看到内存回落。
注意:不要每加载一个模型就调用
Resources.UnloadUnusedAssets,它的开销不小,频繁调用会让加载过程卡顿。建议在内存峰值监控到超过阈值时,或者模型切换的空闲期再调用。
5. 典型问题排查与性能优化清单
这部分是长期填坑攒出来的经验,我按问题出现的频率排序,整理成一个小型速查表。
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
| 模型加载后旋转不对/躺倒 | 源文件坐标轴并非Y-up | 根节点加Quaternion.Euler(90,0,0)修正 |
| 模型显示为全黑 | 法线缺失或错误 | 构建后RecalculateNormals()或检查索引重映射 |
| 模型面片重叠/闪烁 | 三角形索引重复或顶点未去重 | 检查obj的f索引解析,确保顶点Map正确 |
| 模型加载后巨大/微小 | DCC单位不一致 | 提供scale参数,或读取文件单位字段 |
| 有贴图但显示纯色 | Texture加载失败 | 确认贴图路径、改用LoadImage字节流加载 |
| 透明模型变成不透明 | 材质透明度设置缺失 | 设置Material.renderingMode为Transparent,调整_Mode |
| 大模型加载时界面卡死 | 同步解析阻塞主线程 | 改用协程+后台线程解析 |
| 反复加载后内存只增不减 | 旧Mesh/Texture未释放 | Destroy旧资源,灵活调用Resources.UnloadUnusedAssets |
| 某些fbx文件加载失败 | 二进制版本/编码不支持 | 用编辑器预转换方案,避免运行时直接解析fbx |
5.1 面数规范和性能基调
做PC端工具时,很多项目不太在意面数,结果模型动辄几十上百万面,加载后FPS直接个位数。作为参考,我实践下来一套面数规范:
- 角色模型:8千到2万面,远景LOD可以到3千面
- 场景小物件:几千面以内
- 建筑或设备单体展示:5万到20万面,超过30万面建议做减面或LOD
- 整体场景:尽量控制在100万面以内,再多就要考虑分块加载和遮挡剔除
加载器本身也做了优化:构建Mesh时用MeshDataArray替代旧API,可以显著降低顶点缓冲的内存碎片。Unity2019.3以上提供了Mesh.AllocateWritableMeshData这套API,可以在不产生GC压力的前提下填充网格数据,对频繁加载场景很有帮助。不过这个API用起来复杂一些,如果项目不是特别在意性能,可以先不上。
5.2 面数过高时的LOD处理
加载进来的模型动辄面数爆炸,直接显示不是不行,但交互(比如旋转、缩放、右键漫游)会很卡。我的做法是在加载流程里做一个“自动LOD判断”:如果检测到网格顶点数超过某个阈值(比如10万顶点),就自动生成简化Mesh。
Unity编辑器里可以用MeshUtility.Optimize做网格优化,但运行时没有这个API可用。替代方案有两个:一是用Mesh.CombineMeshes做模型合并,减少DrawCall,但这个不减少总面数;二是算法级的网格简化——实现起来比较重,不太适合临时加载场景。
实际项目中我更推荐“加载时强制减半”策略:用Mesh.SetTriangles重建一个降低密度的三角网格。具体做法是在解析阶段就做一次简化采样,比如每隔一个三角形去掉一个。这个粗糙简化会牺牲一些质量,但对于预览类工具来说,交互流畅度比视觉精度重要得多。做数字孪生大场景时,我经常把这种简化后的模型再分块加载,画面流畅度能提升好几个档次。
5.3 材质变体多时的性能风险
一个模型带十几二十个材质是很常见的,尤其是建筑工程模型,每个构件一个材质。这样Material数量膨胀,DrawCall也会跟着膨胀。加载器里我加了个“材质合并”选项:如果检测到材质数量超过某个上限(比如8个),就把同色的材质合并成一个,或者在Shader层面做纹理图集(Atlas)。纹理图集实现要复杂的多,但效果立竿见影。我在一个展厅互动项目中,把37个材质的模型合并成6个图集材质,DrawCall从100多降到十几,体感完全不一样。
6. 一些用得上的细节和扩展思路
6.1 给模型加交互控制
模型加载完,通常会配套一个浏览交互功能:鼠标左键旋转、滚轮缩放、右键平移。这里我建议不要直接改模型Transform旋转,而是把模型放在一个空的GameObject“容器”下,交互时操作容器,而不是操作模型本身。这样后续如果要更换模型(比如加载另一个楼层或设备),只需要把新模型挂到容器下,交互状态不丢失,代码也更清晰。
一个很常见的复合操作写法:
private float rotateSpeed = 5f; private float zoomSpeed = 2f; void Update() { if (Input.GetMouseButton(0)) { float dx = Input.GetAxis("Mouse X"); float dy = Input.GetAxis("Mouse Y"); container.transform.Rotate(Vector3.up, -dx * rotateSpeed, Space.World); container.transform.Rotate(Vector3.right, dy * rotateSpeed, Space.World); } float scroll = Input.GetAxis("Mouse ScrollWheel"); if (Mathf.Abs(scroll) > 0.01f) { Vector3 scale = container.transform.localScale; float factor = 1f + scroll * zoomSpeed; factor = Mathf.Clamp(factor, 0.8f, 1.2f); container.transform.localScale = scale * factor; } }6.2 处理透明模型和遮挡关系
建筑模型里玻璃、栏杆这类半透明物体很常见。如果是单面网格还带半透明材质,显示效果很容易出问题——透过玻璃看到后面的物体会错乱。Unity的渲染队列里,透明物体要排在场景最后,而且关闭深度写入。材质设置上记得把Queue调到Transparent(3000)并设置ZWrite Off。
mat.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha); mat.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha); mat.SetInt("_ZWrite", 0); mat.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent;如果是URP管线,记得用Shader.Find("Universal Render Pipeline/Lit")而不是Standard,URP工程里Standard Shader可能会显示成洋红色。
6.3 多模型合批与场景组织
如果一帧里显示多个模型,尤其是数字孪生场景里可能有几十上百个构件,每个构件一个MeshRenderer就会产生大量DrawCall。性能敏感时,可以考虑把静态的同材质模型合并。可以用Mesh.CombineMeshes,但它要求输入的网格的顶点格式(position/normal/uv)完全一致,不然合并结果会错乱。我在处理Revit导出的建筑构件时,通常是先把网格标准化(统一顶点格式),再按材质分组合并。
要注意合并后的网格内存占用会比原来大不少,因为顶点没有做去重(不同网格的顶点即使坐标一样也是独立存储)。如果合并后的模型要达到展示级而不是单纯性能优化,推荐对合并后的网格跑一遍Mesh.Optimize,把公用顶点合并掉,能省不少内存。
6.4 扩展:运行时GLTF和OBJ之外的格式
如果你要做的产品不限于fbx/obj,还可以把目光放到glTF这类“面向运行时”的格式上。glTF的设计初衷就是Web/移动端实时渲染,自带JSON结构、二进制缓冲、纹理内嵌,解析成本比fbx低得多。市面上不少开源库(比如glTFast)提供了非常成熟的C#运行时加载方案,兼容性已经做得很好。如果你的模型来源广泛、以外部上传为主,可以在加载器里做多格式支持:obj走自研解析器,glTF走glTFast,fbx走编辑器预转换,这样覆盖面就完整了。
7. 收尾:一些个人经验
做这套模型加载器断断续续花了我大半年,踩过的坑里面,最“隐蔽”的一个就是obj解析里的顶点索引重映射。表面上看问题不大,但一旦UV拆边多,索引对不齐,渲染结果就是各种裂缝和黑面。另一个让我印象深刻的坑是坐标系修正——有个建筑设计方的模型用Z轴向上,我在编辑器里预览没问题,但发布到客户机器上安装运行后,模型是横躺的,排查半天发现是客户机器的3ds Max插件设置导出了不同坐标约定。后来学乖了,加载器里留了一个“坐标系切换”的开关,默认Y-up,遇到问题让客户端手动切换,基本能覆盖90%的情况。
如果你也是正在做类似功能,我的建议是:先把obj跑通,验证整体流程(解析、构建Mesh、材质、交互、卸载),再考虑fbx的支持。obj虽然功能少,但胜在可控、简单,能让你建立起对“运行时加载模型”这件事的完整直觉。fbx这种复杂格式,能交给编辑器/转换器处理的,就别在运行时硬啃。这样开发效率高,稳定性也有保障。
本文还有配套的精品资源,点击获取