简介:这份源码工程面向Unity开发者与AR/VR、游戏工具链方向的技术人员,解决运行时动态导入并预览3D模型的实际需求。工程基于TriLib 2.3.7插件,在Unity 2021.3.27与Standard Render Pipeline下实现选择模型、加载并即时预览的完整流程,可迁移到模型替换、关卡编辑器、可视化展示等场景。压缩包共879个文件,约26.37MB,包含94个cs脚本承载核心逻辑与UI交互,145个dll与411个meta支撑插件依赖和资源引用,另有shader、mat、fbx、obj、gltf相关资源及unity场景文件,覆盖模型解析、材质映射与渲染管线适配等模块。已有270人学习。读者可获得可直接运行的工程结构、TriLib在SRP下的导入配置思路、UI与加载逻辑的代码组织方式,以及跨平台模型格式支持的参考实现,便于快速搭建自己的运行时模型导入功能。
1. 运行时加载模型:为什么你的 Unity 项目需要 TriLib 而不是 AssetBundle
做过 Unity 项目的人大概率都遇到过这个需求:用户想把自己电脑里的一个 FBX 或 OBJ 文件拖进你的应用里,立刻在场景中看到它。这个需求听起来简单,但在 Unity 原生工作流里,模型必须在编辑器阶段就导入并打成 AssetBundle 或 Addressable,运行时想加载一个外部模型文件,原生 API 基本帮不上忙。TriLib 这个插件解决的正是这个断层——它让 Unity3D 在运行时通过 C# 直接解析 FBX、OBJ、GLTF、STL 等格式,把网格、材质、贴图、骨骼一次性还原成场景里的 GameObject。
这个方案适合谁?做家装预览工具的需要用户上传户型模型,做工业数字孪生的需要现场加载设备模型,做教育演示的需要学生自己导入作品。这些场景的共同点是:模型来源不可控、格式不统一、不能提前打包。TriLib 的价值就在于把「导入」这个动作从编辑器搬到了运行时,而且全程用 C# 完成,不需要外部进程或格式转换服务。下面我会按实际工程落地的顺序,把选型理由、核心 API、参数配置和踩坑记录讲清楚。
2. TriLib 的加载流程与核心 API:从文件路径到场景 GameObject
2.1 为什么选 TriLib 而不是自己写解析器
Unity 运行时加载外部模型,常见做法有三条路。第一条是让用户先把模型转成 Unity 支持的格式再打包,但这违背了「运行时导入」的初衷。第二条是用 Assimp 的 C# 绑定,功能强但集成成本高,纹理加载和材质映射需要自己处理。第三条就是 TriLib,它把 Assimp 的能力封装成了 Unity 原生的工作流,材质会自动匹配 Standard Shader 或 URP Lit,纹理会自动创建 Texture2D 并赋值,骨骼和动画也会尽量还原。
我选 TriLib 的核心理由是它的加载回调机制足够细。你可以在模型加载完成、纹理加载完成、每个网格创建完成等节点插入自己的逻辑,这对于需要做模型后处理(比如统一缩放、替换材质、计算包围盒)的场景非常关键。另外它支持从文件路径、字节数组、Stream 三种数据源加载,意味着你既可以从本地磁盘读,也可以从网络下载后直接加载,不需要先落盘。
2.2 最小可运行代码:从本地路径加载一个 FBX
下面这段代码是 TriLib 最基础的用法,挂在任意 GameObject 上即可运行。它做了三件事:创建加载选项、指定模型路径、在回调里把加载结果放到场景中。
using UnityEngine; using TriLibCore; using TriLibCore.General; public class RuntimeModelLoader : MonoBehaviour { // 在 Inspector 中指定模型文件的绝对路径 public string modelFilePath = @"C:\Models\sample.fbx"; void Start() { // 创建加载选项,后续所有行为都通过这个对象控制 var assetLoaderOptions = AssetLoader.CreateDefaultLoaderOptions(); // 指定加载完成后的回调 assetLoaderOptions.OnLoad = OnModelLoaded; // 从文件路径加载,TriLib 会自动识别格式 AssetLoader.LoadModelFromFile(modelFilePath, null, null, null, null, assetLoaderOptions); } private void OnModelLoaded(AssetLoaderContext context) { if (context.RootGameObject == null) { Debug.LogError("模型加载失败:RootGameObject 为空"); return; } // 把加载出来的模型挂到当前对象下,方便统一管理 context.RootGameObject.transform.SetParent(this.transform, false); // 打印模型的基本信息,便于调试 var meshFilters = context.RootGameObject.GetComponentsInChildren<MeshFilter>(); Debug.Log($"加载完成,网格数量:{meshFilters.Length}"); } }这段代码里AssetLoader.CreateDefaultLoaderOptions()返回的AssetLoaderOptions对象是核心配置载体。OnLoad回调的AssetLoaderContext参数包含了加载结果、原始路径、错误信息等。LoadModelFromFile的第一个参数是文件路径,第二个到第五个参数分别是纹理加载回调、材质加载回调、网格加载回调和错误回调,传 null 表示使用默认行为。
2.3 从字节数组加载:网络下载后直接解析
很多场景下模型是从服务器下载的,先写磁盘再加载会多一次 IO。TriLib 支持直接从字节数组加载,配合 UnityWebRequest 可以做到下载完立刻解析。
using UnityEngine; using UnityEngine.Networking; using TriLibCore; using System.Collections; public class WebModelLoader : MonoBehaviour { public string modelUrl = "https://example.com/models/part.glb"; IEnumerator Start() { using (var request = UnityWebRequest.Get(modelUrl)) { yield return request.SendWebRequest(); if (request.result != UnityWebRequest.Result.Success) { Debug.LogError($"下载失败:{request.error}"); yield break; } byte[] modelBytes = request.downloadHandler.data; var options = AssetLoader.CreateDefaultLoaderOptions(); options.OnLoad = OnLoaded; // 从内存字节数组加载,不经过磁盘 AssetLoader.LoadModelFromMemory(modelBytes, null, null, null, null, options); } } private void OnLoaded(AssetLoaderContext context) { if (context.RootGameObject != null) { context.RootGameObject.transform.SetParent(transform, false); } } }LoadModelFromMemory的签名和LoadModelFromFile类似,只是第一个参数从路径变成了byte[]。这里有个细节:如果模型文件里引用了外部纹理(比如 OBJ + MTL + 多张 PNG),从内存加载时 TriLib 无法自动找到这些纹理文件,需要你通过options.ExternalDataMapper或纹理回调手动提供。所以从内存加载更适合 GLB 这种自包含格式,或者 FBX 已经嵌入了纹理的情况。
2.4 关键参数配置:缩放、坐标系与材质
TriLib 的AssetLoaderOptions有几十个参数,下面这几个是实际项目里必须关注的。
| 参数名 | 默认值 | 作用 | 建议 |
|---|---|---|---|
ScaleFactor | 1.0 | 全局缩放系数 | 根据模型单位调整,厘米转米设 0.01 |
ConvertToLeftHanded | false | 坐标系转换 | 从 3ds Max 导出的模型通常需要开启 |
UseUnityNativeNormalCalculator | false | 法线计算方式 | 模型法线异常时开启 |
AutoHideDisabledObjects | true | 隐藏禁用对象 | 保持默认即可 |
GenerateMeshColliders | false | 生成碰撞体 | 需要物理交互时开启 |
MarkTexturesNoLongerReadable | true | 释放纹理内存 | 大模型场景保持开启 |
ScaleFactor是最容易翻车的参数。不同 DCC 工具导出的模型单位不一致,有的按厘米,有的按米,有的按英寸。如果加载后发现模型巨大或极小,先检查这个值。ConvertToLeftHanded涉及坐标系手性,Unity 使用左手坐标系,而部分建模软件使用右手坐标系,开启后 TriLib 会自动翻转 Z 轴并调整法线方向。
3. 材质与纹理的运行时处理:让导入的模型不变成紫色方块
3.1 材质映射的默认行为与局限
TriLib 加载模型时会尝试把原始材质转换成 Unity 的材质。默认情况下,它会根据当前渲染管线选择 Standard Shader 或 URP Lit Shader,然后把漫反射贴图、法线贴图、金属度贴图等赋值到对应槽位。但这里有个现实问题:不同建模软件导出的材质命名和参数差异极大,TriLib 的自动映射不可能覆盖所有情况。
我遇到过最典型的情况是:模型在 Blender 里看起来正常,导入 Unity 后变成纯紫色。紫色意味着 Shader 找不到或者材质创建失败。原因通常是模型使用了自定义 Shader 或者材质引用了不存在的纹理路径。TriLib 在找不到纹理时会创建一个占位纹理,但如果 Shader 本身不兼容,材质就会显示异常。
3.2 用材质回调统一替换 Shader
解决材质问题的可靠方式是在加载过程中拦截材质创建,用自己的逻辑替换 Shader。TriLib 提供了OnMaterialLoad回调,可以在材质创建后、赋值前修改它。
using UnityEngine; using TriLibCore; using TriLibCore.General; public class MaterialOverrideLoader : MonoBehaviour { public Shader targetShader; // 在 Inspector 中指定,比如 URP/Lit void Start() { var options = AssetLoader.CreateDefaultLoaderOptions(); options.OnLoad = OnLoaded; // 注册材质加载回调 options.OnMaterialLoad = OnMaterialCreated; AssetLoader.LoadModelFromFile(@"C:\Models\part.fbx", null, null, null, null, options); } private bool OnMaterialCreated(AssetLoaderContext context, Material material) { // 返回 true 表示接管该材质的后续处理 if (targetShader != null) { // 保存原始贴图引用 Texture mainTex = material.HasProperty("_MainTex") ? material.GetTexture("_MainTex") : null; // 替换 Shader material.shader = targetShader; // 把原始贴图重新赋给新 Shader 的主纹理槽位 if (mainTex != null && material.HasProperty("_BaseMap")) { material.SetTexture("_BaseMap", mainTex); } } return true; } private void OnLoaded(AssetLoaderContext context) { if (context.RootGameObject != null) { context.RootGameObject.transform.SetParent(transform, false); } } }OnMaterialLoad回调的返回值很关键:返回true表示你已经处理了材质,TriLib 不会再做默认赋值;返回false则继续默认流程。上面的代码先取出原始主贴图,替换 Shader 后再把贴图赋给新 Shader 的对应属性。注意 URP Lit 的主纹理属性名是_BaseMap,而 Standard Shader 是_MainTex,这个差异不处理就会导致贴图丢失。
3.3 纹理加载的异步与内存管理
TriLib 默认使用异步方式加载纹理,这意味着模型网格可能先于纹理出现在场景中。如果你需要在纹理全部就绪后再做某些操作(比如截图、上传),需要监听OnTextureLoad回调并计数。
private int totalTextures = 0; private int loadedTextures = 0; private void SetupTextureTracking(AssetLoaderOptions options) { options.OnTextureLoad = (context, texture) => { loadedTextures++; // 可以在这里做纹理压缩、尺寸限制等处理 if (texture.width > 2048 || texture.height > 2048) { Debug.LogWarning($"纹理尺寸过大:{texture.width}x{texture.height}"); } }; }内存管理是运行时加载模型最容易忽视的问题。每次加载都会创建新的 Mesh、Material、Texture,如果不手动释放,反复加载几次后内存就会飙升。TriLib 提供了AssetLoaderContext.Dispose()方法,在模型不再需要时调用它可以释放关联的资源。但要注意:如果模型已经挂在场景中并且还在渲染,直接 Dispose 会导致引用丢失。正确的做法是先 Destroy 对应的 GameObject,再释放 context。
4. 避坑与排查:运行时加载模型最常见的五类翻车
4.1 模型加载成功但场景里什么都看不到
现象:控制台没有报错,OnLoad回调也触发了,但场景里就是没有模型。
原因通常有三个。第一是模型缩放比例不对,可能只有 0.001 倍大小,肉眼看不见。第二是模型被加载到了相机视野之外,TriLib 默认把模型放在世界原点,但模型原始坐标可能偏移很远。第三是材质透明或 Shader 不兼容导致渲染失败。
解决:在OnLoad里打印context.RootGameObject的世界坐标和包围盒大小,然后手动把相机对准它。如果是缩放问题,调整ScaleFactor。如果是坐标偏移,计算包围盒中心并把模型平移到原点。
4.2 纹理丢失或显示为纯色
现象:模型形状正确,但表面是纯灰或纯白,没有贴图。
原因:TriLib 从文件加载时,如果模型引用了外部纹理文件(比如 OBJ 旁边的 PNG),而纹理路径是相对路径,TriLib 可能找不到。另外从内存加载时,外部纹理完全无法自动定位。
解决:对于文件加载,确保模型和纹理在同一目录且文件名匹配。对于内存加载,使用options.ExternalDataMapper提供纹理数据,或者在OnTextureLoad回调里手动替换纹理。也可以考虑在加载前把模型和纹理打包成 GLB 格式,GLB 是自包含的,不存在路径问题。
4.3 加载大模型时卡顿甚至崩溃
现象:加载一个几十兆的 FBX 时,主线程卡住几秒,或者直接内存溢出。
原因:TriLib 的解析过程默认在主线程执行,大模型的网格和纹理创建会阻塞渲染。另外如果模型面数极高(几十万面),Unity 的 Mesh 创建本身就很耗时。
解决:使用AssetLoader.LoadModelFromFileAsync异步版本,它会把解析放在后台线程,但 Unity 的 Mesh 和 Texture 创建仍然必须在主线程。对于超大模型,建议在加载前做减面处理,或者分块加载。另外开启MarkTexturesNoLongerReadable可以释放纹理的 CPU 端内存,减少内存占用。
4.4 骨骼动画丢失或错位
现象:带骨骼的 FBX 加载后,模型变成 T-Pose 或者骨骼错位。
原因:TriLib 对骨骼动画的支持取决于模型导出时的设置。如果 FBX 里没有嵌入动画数据,或者骨骼绑定方式不兼容,就会丢失。另外 Unity 的 Avatar 配置也需要匹配。
解决:确认 FBX 导出时勾选了动画和骨骼。加载后检查context.RootGameObject下是否有Animator组件。如果有 Animator 但没有 Avatar,需要手动创建或指定。对于人形骨骼,建议在加载后通过AnimatorAvatar重新映射。
4.5 重复加载导致内存持续增长
现象:每次加载新模型,内存就涨一截,加载十几个模型后应用崩溃。
原因:每次加载创建的 Mesh、Material、Texture 没有被释放。Unity 的 GC 不会自动回收这些原生资源,必须手动 Destroy 或 Dispose。
解决:维护一个加载结果的引用列表,在加载新模型前先销毁旧模型。调用Destroy(oldModelGameObject)后,再调用对应AssetLoaderContext.Dispose()。注意顺序不能反,先销毁 GameObject 再释放 context,否则会出现引用空指针。
5. 进阶技巧:用加载后处理管线统一模型规格
5.1 自动居中与归一化缩放
实际项目里,用户导入的模型大小和位置千奇百怪。一个实用的技巧是在加载完成后自动计算包围盒,把模型缩放到统一尺寸并居中。这样后续的相机控制和交互逻辑就不需要针对每个模型单独适配。
using UnityEngine; using TriLibCore; public class ModelNormalizer : MonoBehaviour { public float targetSize = 1.0f; // 归一化后的最大边长 public void NormalizeModel(GameObject modelRoot) { // 计算所有渲染器的包围盒 var renderers = modelRoot.GetComponentsInChildren<Renderer>(); if (renderers.Length == 0) return; Bounds bounds = renderers[0].bounds; foreach (var r in renderers) { bounds.Encapsulate(r.bounds); } // 计算缩放系数,把最大边长缩放到 targetSize float maxExtent = Mathf.Max(bounds.size.x, bounds.size.y, bounds.size.z); if (maxExtent > 0.0001f) { float scale = targetSize / maxExtent; modelRoot.transform.localScale *= scale; } // 重新计算包围盒并平移到原点 bounds = renderers[0].bounds; foreach (var r in renderers) { bounds.Encapsulate(r.bounds); } modelRoot.transform.position -= bounds.center; } }这段代码的关键点是:先算包围盒得到最大边长,再算缩放系数,缩放后重新计算包围盒并平移。注意renderers[0].bounds在缩放后需要重新获取,因为 bounds 是世界空间的,缩放会改变它。这个归一化逻辑可以挂在OnLoad回调里,对每个加载的模型自动执行。
5.2 用后处理回调插入自定义逻辑
TriLib 的OnLoad回调是最适合做后处理的入口。除了归一化,还可以在这里做材质替换、LOD 生成、碰撞体添加等操作。我一般会把后处理逻辑封装成独立的组件,在回调里动态挂载到模型根节点上。
private void OnLoaded(AssetLoaderContext context) { if (context.RootGameObject == null) return; var root = context.RootGameObject; root.transform.SetParent(transform, false); // 挂载归一化组件并执行 var normalizer = root.AddComponent<ModelNormalizer>(); normalizer.NormalizeModel(root); // 挂载旋转控制器,方便用户查看 root.AddComponent<ModelRotator>(); // 如果需要物理交互,给所有网格添加碰撞体 foreach (var meshFilter in root.GetComponentsInChildren<MeshFilter>()) { var collider = meshFilter.gameObject.AddComponent<MeshCollider>(); collider.sharedMesh = meshFilter.sharedMesh; } }这种「回调 + 组件」的模式好处是解耦:加载逻辑只管加载,后处理逻辑独立成组件,方便复用和调试。ModelRotator可以是一个简单的脚本,让模型自动缓慢旋转,方便用户从各个角度查看。
5.3 验证加载结果是否正确的检查清单
加载完成后,不要只看「有没有报错」,建议按下面这个清单逐项确认:
| 检查项 | 方法 | 预期结果 |
|---|---|---|
| 网格数量 | GetComponentsInChildren<MeshFilter>().Length | 与原始模型一致 |
| 材质数量 | GetComponentsInChildren<Renderer>().Length | 每个网格都有 Renderer |
| 纹理赋值 | 检查material.mainTexture != null | 有贴图的材质不为空 |
| 包围盒尺寸 | 计算 Renderer 的 bounds | 在合理范围内 |
| 骨骼数量 | GetComponentsInChildren<Transform>().Length | 带骨骼模型应有额外 Transform |
| 动画片段 | GetComponent<Animator>()?.runtimeAnimatorController | 有动画的模型不为空 |
这个清单我每次集成 TriLib 到新项目时都会跑一遍,能快速定位是加载失败、材质丢失还是缩放异常。尤其是包围盒尺寸,很多「模型看不见」的问题都是因为尺寸是 0.001 或者 10000。
5.4 一个我踩过的坑:异步加载的回调顺序
最后说一个血泪经验。TriLib 的异步加载LoadModelFromFileAsync虽然不阻塞主线程,但它的回调触发顺序和同步版本不同。同步版本里OnLoad触发时所有纹理和材质都已经就绪,但异步版本里OnLoad可能在纹理还没加载完时就触发了。如果你在OnLoad里直接读取material.mainTexture,可能会拿到 null。
解决方式是在OnLoad里不要假设纹理已就绪,而是通过OnTextureLoad计数,等所有纹理加载完成后再执行依赖纹理的逻辑。或者干脆用同步加载,对于中小模型来说,同步加载的卡顿通常在可接受范围内,反而省去了回调顺序的玄学问题。
希望帮到你。
本文还有配套的精品资源,点击获取