ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity运行时动态加载FBX/OBJ模型:解析、实现与工程实践

Unity运行时动态加载FBX/OBJ模型:解析、实现与工程实践 简介本资源是一套基于TriLib 2.1.7的Unity运行时外部模型动态加载解决方案面向Unity中级开发者及三维应用开发工程师解决游戏、仿真、数字孪生等场景中需在程序运行时灵活加载本地.fbx/.obj模型的核心需求。资源包共622个文件含89个C#脚本实现加载逻辑与映射器、18个DLLTriLib核心库及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格式版本多、二进制解析繁琐、开发成本高深度定制、离线工具链后端预转换稳定可控、运行时轻量需要额外写转换工具产品级工具、数字孪生/BIMAssetBundle官方支持、性能好仅限编辑器内导入运行时外部文件不可用游戏内容更新、内置模型2. 格式原理解读obj和fbx到底在存什么要正确写解析器必须先搞懂模型文件内部存的是什么。很多Unity新手一上来就搜“加载fbx的API”结果发现Unity官方根本没有这种API——因为这本质上不是“加载”而是“解析后重建网格”。2.1 obj其实是一个纯文本索引表obj文件是最直观的文本格式每一行一个数据项。核心就几种v x y z顶点坐标一行一个顶点vt u vUV坐标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法线和LayerElementUVUV。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 ListVector3 positions new ListVector3(); public ListVector2 uvs new ListVector2(); public ListVector3 normals new ListVector3(); public ListVector3 finalVerts new ListVector3(); public ListVector2 finalUvs new ListVector2(); public ListVector3 finalNormals new ListVector3(); public Listint triangles new Listint(); public DictionaryVector3Int, int vertexMap new DictionaryVector3Int, int(); }vertexMap的键是(positionIndex, uvIndex, normalIndex)的三元组。每读到一个面对它的每个顶点做查表如果这个三元组已经在字典里直接用对应的最终顶点索引如果不在就新建一个最终顶点往finalVerts/finalUvs/finalNormals里添加数据并把这个新索引记录到字典里。关键代码逻辑大概长这样// 解析 f 行 string[] parts line.Split( ); Listint faceIndices new Listint(); 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.LoadAssetAtPathGameObject(path); MeshFilter[] filters prefab.GetComponentsInChildrenMeshFilter(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 协程还是TaskUnity里两个选项都可行我倾向于用协程做文件解析用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.AddComponentMeshFilter().mesh mesh; go.AddComponentMeshRenderer().material defaultMat; }真正耗时的解析被丢到线程池主线程只是每帧检查一下是否完成开销很小。Debug.Log里看性能的话加载一个2万面的obj原来同步要800ms改成异步后主线程几乎无感知体感就是“点了确定模型转个圈就出来了”。4.2 缓存和重复加载同一个模型文件用户可能加载了一次又一次。与其每次重新解析、重新构建Mesh不如做个简单的缓存字典。以文件路径最后修改时间为key缓存内容和构建好的Mesh对象。这样第二次加载相同文件时命中缓存直接秒开。public class ModelCache { private static Dictionarystring, Mesh _meshCache new Dictionarystring, 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文件加载失败二进制版本/编码不支持用编辑器预转换方案避免运行时直接解析fbx5.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调到Transparent3000并设置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)而不是StandardURP工程里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走glTFastfbx走编辑器预转换这样覆盖面就完整了。7. 收尾一些个人经验做这套模型加载器断断续续花了我大半年踩过的坑里面最“隐蔽”的一个就是obj解析里的顶点索引重映射。表面上看问题不大但一旦UV拆边多索引对不齐渲染结果就是各种裂缝和黑面。另一个让我印象深刻的坑是坐标系修正——有个建筑设计方的模型用Z轴向上我在编辑器里预览没问题但发布到客户机器上安装运行后模型是横躺的排查半天发现是客户机器的3ds Max插件设置导出了不同坐标约定。后来学乖了加载器里留了一个“坐标系切换”的开关默认Y-up遇到问题让客户端手动切换基本能覆盖90%的情况。如果你也是正在做类似功能我的建议是先把obj跑通验证整体流程解析、构建Mesh、材质、交互、卸载再考虑fbx的支持。obj虽然功能少但胜在可控、简单能让你建立起对“运行时加载模型”这件事的完整直觉。fbx这种复杂格式能交给编辑器/转换器处理的就别在运行时硬啃。这样开发效率高稳定性也有保障。本文还有配套的精品资源点击获取
返回列表