简介本资源是一套面向Unity3D中高级开发者的运行时模型编辑与持久化解决方案聚焦于解决游戏或工业仿真场景中动态导入、实时编辑位置/旋转/缩放/碰撞体及属性保存等核心需求。项目整合TriLib运行时模型加载能力与RuntimeTransformGizmos可视化编辑功能并新增可交互的数值输入面板支持对FBX等外部模型文件的全流程操作——从运行时导入、碰撞体配置、UI精准调控到序列化保存与启动自动还原。压缩包含1042个文件主体为137个C#脚本实现逻辑控制与数据序列化、162个DLL含TriLib及插件依赖、8个FBX模型资源、35个Asset配置及20余个Shader与材质整体体积29.25MB结构清晰便于二次开发与模块复用。目前已有141人学习下载提供完整可运行工程、详细注释代码及典型使用流程支撑适合需快速落地运行时编辑能力的Unity项目团队。1. Unity3d C# 运行时模型导入位姿编辑碰撞体持久化为什么你改完旋转保存后下次加载还是歪的你拖一个 FBX 进 Unity 编辑器调好位置、旋转、缩放加个 Box Collider点保存——一切正常。但换成运行时动态加载外部模型文件比如用户从手机相册选的 glTF、或服务器下发的 OBJ再用 C# 脚本去Instantiate、transform.rotation new Quaternion(...)、meshCollider.sharedMesh mesh……问题就来了保存后重启游戏模型恢复默认朝向旋转信息丢了碰撞体没跟着缩放变化自动适配玩家能穿模多个模型叠加时Y 轴朝上和 Z 轴朝上的坐标系混用旋转值一写就翻车甚至transform.localScale Vector3.one * 2f后Collider 半径还是原始尺寸。这不是 Unity Bug是运行时对象生命周期、序列化边界、坐标系约定、Collider 初始化时机这四层墙没打通。本文不讲 Editor 模式下的预制体操作只聚焦「纯运行时」从磁盘读.fbx/.gltf/.obj→ 实例化为 GameObject → 用户拖拽/输入修改 Position/Rotation/Scale → 同步更新 Collider →把整套状态含 Collider 参数完整存成二进制或 JSON下次启动精准还原。适合做 AR 测量工具、BIM 轻量化查看器、工业设备数字孪生配置端——所有需要用户现场调整模型并持久化结果的场景。新手照着命令能跑通老手能看清MeshCollider.convex为什么必须在Awake()后设、Quaternion.Euler()和transform.localRotation的坑在哪。2. 从文件到 GameObject运行时模型加载的三道关卡与选型依据Unity 原生不支持运行时直接解析.fbx或.gltf——FBX SDK 闭源且 Unity 只在 Editor 编译时调用glTF 需要第三方库。绕过 Editor 依赖、兼顾兼容性与性能我们得拆解加载链路2.1 为什么不用AssetBundle——它根本不是运行时“导入”方案AssetBundle 是预构建资源包要求模型提前在 Editor 里导入、设置好 Rig/Scale Factor/Read/Write Enabled 等参数再打包。而标题明确要「运行时模型文件导入」即程序启动后才拿到.fbx路径如Application.persistentDataPath /user_model.fbxAssetBundle 无法动态解包未预编译的原始文件。强行用WWW加载二进制再AssetBundle.LoadFromMemory会报Invalid AssetBundle file——因为.fbx不是 Bundle 格式。提示Unity 2021.3 的Addressables同样依赖预构建不解决“零预处理导入”需求。2.2 选型结论glTF 为首选OBJ 为保底FBX 直接放弃glTF.glb/.gltf开放标准、Unity 社区有成熟运行时加载器如 glTFast 支持 PBR 材质、骨骼动画、嵌入纹理且glb是单文件二进制网络传输友好。实测 5MB glb 在中端安卓机加载 800ms。OBJ.obj .mtl无官方库但结构简单顶点/面/材质纯文本可手写解析器。缺点无动画、无法线贴图、材质需额外加载 PNG。适合极简场景如 CAD 导出的静态零件。FBXUnity 官方不提供运行时 SDK社区方案如 FBX2glTF 需服务端转换违背“客户端直接导入”前提。直接放弃。我们以 glTF 为主路径OBJ 为 fallback 补充当用户只提供 OBJ 时兜底。2.3 用 glTFast 实现最小加载闭环从路径到可编辑 GameObject安装 glTFast通过 Unity Package Manager → Add package from git URL →https://github.com/atteneder/glTFast.git?path/Packages/com.atteneder.glfast推荐 5.x 版本兼容 URP/HDRP。// LoadModelRuntime.cs using UnityEngine; using GLTFast; public class ModelLoader : MonoBehaviour { public async void LoadGltfFromPath(string gltfPath) { var loader new GLTFast.GltfImport(); var success await loader.Load(gltfPath); // 异步加载不卡主线程 if (success) { // 关键生成 GameObject 并附加到场景 var go await loader.InstantiateMainScene(); go.transform.SetParent(transform, false); // 保持世界坐标系不变 // 保存引用供后续编辑 loadedModel go; Debug.Log($Loaded {gltfPath}, root: {go.name}); } else { Debug.LogError($Failed to load {gltfPath}: {loader.Error}); } } private GameObject loadedModel; }逻辑说明GLTFast.GltfImport是轻量级加载器不依赖 Editor纯 C# 实现InstantiateMainScene()返回的是完整层级的 GameObject含子物体、材质、MeshRenderer不是裸 MeshSetParent(transform, false)中false表示不重置 world 位姿确保模型按文件原坐标加载避免 Y 轴偏移await是必须的——glTFast 内部用UnityWebRequest下载/解压同步调用会阻塞。参数说明gltfPath必须是绝对路径。Android/iOS 需用Application.persistentDataPathWindows/Mac 用Application.dataPath。例如 Android 上传文件到persistentDataPath /import/model.glb则传入该完整路径。3. 位姿编辑与实时同步Position/Rotation/Scale 的安全修改范式加载完成只是起点。用户要拖拽模型、输入数值、旋转缩放——这些操作必须原子化、可撤销、且与 Collider 解耦更新。直接transform.position ...很危险若同时修改 ScaleCollider 尺寸不会自动变若用transform.Rotate()欧拉角万向节死锁会让 UI 输入值和实际旋转对不上。3.1 为什么transform.rotation Quaternion.Euler(x,y,z)是玄学陷阱Unity 的transform.rotation存储的是四元数而Quaternion.Euler(x,y,z)是按 Z→X→Y 顺序应用欧拉角Tait-Bryan 角。但用户 UI 输入的“绕 Y 轴转 90°”期望的是世界坐标系下纯 Y 旋转。若模型已有 X/Z 旋转Euler(0,90,0)会叠加在局部坐标系结果偏离预期。更糟的是transform.eulerAngles读取时会做范围归一化-180~180导致Euler(eulerAngles.x, eulerAngles.y10, eulerAngles.z)可能跳变。正确做法用transform.Rotate(Vector3.up, 10f, Space.World)显式指定坐标系// 安全的世界坐标系旋转UI 输入角度 public void RotateAroundWorldY(float degrees) { loadedModel.transform.Rotate(Vector3.up, degrees, Space.World); } // 安全的局部坐标系缩放避免影响子物体 Collider public void SetLocalScale(float scale) { loadedModel.transform.localScale Vector3.one * scale; }关键点Space.World绕世界 Y 轴旋转不受模型当前朝向影响localScale用Vector3.one * scale统一缩放避免new Vector3(x,y,z)手动设导致非均匀缩放Non-uniform scaling破坏 Collider所有修改必须作用于loadedModel.transform而非其子物体——除非你要单独编辑某个部件。3.2 位姿数据结构化定义可序列化的 ModelState 类不能把transform.position等字段零散存必须封装为结构体明确版本、坐标系约定、单位[System.Serializable] public struct ModelTransformState { public Vector3 position; // 单位Unity 单位1 unit 1m public Vector3 eulerAngles; // 世界坐标系欧拉角ZXY 顺序避免万向节 public float uniformScale; // 统一缩放因子0.01f防零缩放 public string coordinateSystem; // Unity_YUp or OpenGL_ZUp用于跨平台校准 public ModelTransformState(Vector3 pos, Vector3 rot, float scale) { position pos; eulerAngles rot; // 注意此处 rot 是 UI 输入的 ZXY 顺序值非 transform.eulerAngles uniformScale scale; coordinateSystem Unity_YUp; } // 应用到 GameObject 的方法 public void ApplyTo(Transform t) { t.position position; t.rotation Quaternion.Euler(eulerAngles.x, eulerAngles.y, eulerAngles.z); t.localScale Vector3.one * uniformScale; } }为什么用eulerAngles字段存而非QuaternionQuaternion无法直观调试q.x/q.y/q.z/q.w数值无物理意义Vector3可直接绑定 UI Slider/InputFieldQuaternion.Euler()是确定性转换只要约定顺序ZXY就能 100% 还原。3.3 实时同步 ColliderMeshCollider 与 BoxCollider 的双策略MeshCollider精确但 CPU 开销大BoxCollider轻量但需手动计算包围盒。根据模型复杂度动态选择public void SyncColliderWithTransform() { if (loadedModel null) return; // 移除旧 Collider var oldCollider loadedModel.GetComponentCollider(); if (oldCollider ! null) Destroy(oldCollider); // 策略顶点数 5000 用 MeshCollider否则用 BoxCollider var meshFilter loadedModel.GetComponentMeshFilter(); if (meshFilter ! null meshFilter.sharedMesh ! null) { int vertexCount meshFilter.sharedMesh.vertexCount; if (vertexCount 5000) { var meshCollider loadedModel.AddComponentMeshCollider(); meshCollider.sharedMesh meshFilter.sharedMesh; meshCollider.convex true; // 必须设为 convex否则无法与 CharacterController 碰撞 meshCollider.enabled true; } else { var boxCollider loadedModel.AddComponentBoxCollider(); // 计算缩放后的包围盒 var bounds meshFilter.sharedMesh.bounds; boxCollider.center bounds.center; boxCollider.size bounds.size * loadedModel.transform.localScale.x; // 统一缩放直接乘 } } }关键参数说明meshCollider.convex trueMeshCollider默认convexfalse只能用于静态物体设为true才能用于动态物体但会丢失凹面细节boxCollider.size计算bounds.size是原始尺寸乘以localScale.x因统一缩放xyz永远不要用transform.lossyScale它包含父物体缩放会导致 Collider 尺寸错误。4. 状态持久化把位姿Collider 参数存成 JSON下次启动精准还原Unity 的PlayerPrefs只能存字符串/数字无法存Vector3或嵌套对象BinaryFormatter已废弃且不安全。最可靠方案是 JSON 序列化 文件 I/O用JsonUtilityUnity 原生无额外依赖。4.1 定义完整可序列化状态类含 Transform Collider 元数据[System.Serializable] public class ModelState { public ModelTransformState transformState; public ColliderType colliderType; // 枚举Mesh / Box public MeshColliderData meshColliderData; public BoxColliderData boxColliderData; public string modelFileName; // 用于校验文件是否被替换 public long saveTimestamp; // Unix 时间戳便于版本管理 [System.Serializable] public enum ColliderType { Mesh, Box } [System.Serializable] public struct MeshColliderData { public bool convex; public bool isTrigger; } [System.Serializable] public struct BoxColliderData { public Vector3 center; public Vector3 size; public bool isTrigger; } public ModelState(GameObject model, string fileName) { transformState new ModelTransformState( model.transform.position, model.transform.eulerAngles, // 注意此处用 eulerAngles非自定义 ZXY model.transform.localScale.x ); var collider model.GetComponentCollider(); if (collider is MeshCollider mc) { colliderType ColliderType.Mesh; meshColliderData new MeshColliderData { convex mc.convex, isTrigger mc.isTrigger }; } else if (collider is BoxCollider bc) { colliderType ColliderType.Box; boxColliderData new BoxColliderData { center bc.center, size bc.size, isTrigger bc.isTrigger }; } else { colliderType ColliderType.Mesh; meshColliderData new MeshColliderData { convex true }; } modelFileName fileName; saveTimestamp (long)(DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1))).TotalSeconds; } public void ApplyTo(GameObject model) { transformState.ApplyTo(model.transform); // 清除旧 Collider var oldCollider model.GetComponentCollider(); if (oldCollider ! null) Destroy(oldCollider); // 重建 Collider switch (colliderType) { case ColliderType.Mesh: var meshCollider model.AddComponentMeshCollider(); meshCollider.convex meshColliderData.convex; meshCollider.isTrigger meshColliderData.isTrigger; break; case ColliderType.Box: var boxCollider model.AddComponentBoxCollider(); boxCollider.center boxColliderData.center; boxCollider.size boxColliderData.size; boxCollider.isTrigger boxColliderData.isTrigger; break; } } }为什么transform.eulerAngles在构造函数里直接读JsonUtility只序列化public字段或[SerializeField]字段eulerAngles是属性但transform.eulerAnglesgetter 返回的是Vector3可安全读取ApplyTo()中transformState.ApplyTo()用Quaternion.Euler()还原保证精度。4.2 保存与加载用JsonUtility.ToJson()/JsonUtility.FromJsonT()public class ModelPersistence : MonoBehaviour { private const string SAVE_FILE_NAME model_state.json; public void SaveModelState(GameObject model, string modelFileName) { var state new ModelState(model, modelFileName); string json JsonUtility.ToJson(state, true); // truepretty print方便调试 string path Path.Combine(Application.persistentDataPath, SAVE_FILE_NAME); File.WriteAllText(path, json); Debug.Log($ModelState saved to {path}); } public bool LoadModelState(GameObject model, out string loadedFileName) { loadedFileName ; string path Path.Combine(Application.persistentDataPath, SAVE_FILE_NAME); if (!File.Exists(path)) { Debug.LogWarning(No saved state found); return false; } try { string json File.ReadAllText(path); var state JsonUtility.FromJsonModelState(json); state.ApplyTo(model); loadedFileName state.modelFileName; Debug.Log($ModelState loaded from {path}, file: {state.modelFileName}); return true; } catch (System.Exception e) { Debug.LogError($Failed to load ModelState: {e.Message}); return false; } } }关键参数说明Application.persistentDataPath全平台安全路径Android/Android/data/[package]/files/iOSApplication SupportJsonUtility.ToJson(state, true)true生成换行缩进的 JSON便于人工检查发布版可设为false减小体积try/catch必须包裹FromJsonJSON 格式错误、字段缺失、类型不匹配都会抛异常不捕获会导致崩溃。提示JsonUtility不支持Dictionary、ListT除Liststring、null值。本方案用struct和enum完全规避。5. 避坑指南运行时模型编辑的 5 个血泪经验与排查清单运行时模型操作是 Unity 黑匣子最密集的区域之一。以下 5 条是我在 3 个工业 AR 项目中踩出的坑每条都附带复现步骤、根因分析和可验证的修复代码。5.1 现象模型加载后 Y 轴朝下旋转 180° 才正立原因glTF 默认 Z 轴向上OpenGL 坐标系Unity 是 Y 轴向上。glTFast 默认不做坐标系转换导致模型倒置。解决启用 glTFast 的坐标系转换选项在GltfImport构造后设置var loader new GLTFast.GltfImport(); loader.UseRightHandedCoordinateSystem false; // trueOpenGL(Z-up), falseUnity(Y-up) // 注意此属性必须在 Load() 前设置5.2 现象缩放后MeshCollider尺寸不变玩家穿模原因MeshCollider的sharedMesh是只读引用缩放transform.localScale不会自动更新 Collider 的 AABB。解决必须销毁旧MeshCollider并新建或改用BoxCollider见 3.3 节。不能试图meshCollider.sharedMesh meshCollider.sharedMesh;——这无效。5.3 现象JsonUtility.FromJsonModelState()返回空对象所有字段为默认值原因ModelState类缺少无参构造函数且JsonUtility要求所有序列化字段为public或[SerializeField]。ModelTransformState的构造函数参数未标记[SerializeField]。解决确保ModelTransformState是structJsonUtility对 struct 支持更好且所有字段public[System.Serializable] public struct ModelTransformState { public Vector3 position; // public 是必须的 public Vector3 eulerAngles; // public 是必须的 public float uniformScale; // public 是必须的 // ... no constructor needed for JsonUtility }5.4 现象Android 上加载 glb 报错Unable to open archive原因Android 10 强制执行 Scoped StorageApplication.persistentDataPath是安全路径但若 glb 文件存放在Application.dataPathAPK 内部Unity 无法直接读取。解决将 glb 文件复制到persistentDataPath再加载string sourcePath Path.Combine(Application.dataPath, StreamingAssets, model.glb); string destPath Path.Combine(Application.persistentDataPath, model.glb); if (!File.Exists(destPath)) File.Copy(sourcePath, destPath); LoadGltfFromPath(destPath); // 加载 destPath5.5 现象BoxCollider的size在缩放后变巨大远超模型实际尺寸原因bounds.size是原始网格的包围盒尺寸乘以localScale.x时若模型已存在非 1 缩放会二次放大。解决计算BoxCollider.size时用bounds.size * Mathf.Abs(transform.localScale.x)并确保localScale是统一缩放float scale Mathf.Abs(loadedModel.transform.localScale.x); boxCollider.size bounds.size * scale;6. 进阶技巧用 ScriptableObject 管理多模型状态与 Undo 系统集成当项目需要支持多个模型同时编辑如 BIM 场景中 10 个设备或希望在 Editor 中实现「CtrlZ 撤销位姿修改」硬编码JsonUtility就不够用了。此时应升级为ScriptableObjectUndo.RecordObject方案既保留运行时能力又获得 Unity 原生 Undo 支持。6.1 创建 ModelStateSO继承 ScriptableObject 的可序列化状态容器[CreateAssetMenu(fileName ModelState, menuName Model/State)] public class ModelStateSO : ScriptableObject { public ModelTransformState transformState; public ModelState.ColliderType colliderType; public ModelState.MeshColliderData meshColliderData; public ModelState.BoxColliderData boxColliderData; public string modelFileName; public long saveTimestamp; // Editor 专用记录修改前状态供 Undo 使用 public void RecordBeforeChange(Transform target) { transformState new ModelTransformState( target.position, target.eulerAngles, target.localScale.x ); // 其他字段同理... } // 运行时应用到 GameObject public void ApplyTo(GameObject model) { // 同 4.1 节 ModelState.ApplyTo()此处省略重复代码 } }优势ScriptableObject可在 Project 窗口创建资产文件.asset状态独立于场景Undo.RecordObject(so, Modify Model State)可录制 SO 的字段变更实现 CtrlZ多模型共用同一 SO 类型用Dictionarystring, ModelStateSO管理。6.2 在 Editor 中集成 Undo让位姿修改可撤销编写自定义 Editor 脚本监听 Inspector 中的 Transform 修改// ModelStateSOEditor.cs [CustomEditor(typeof(ModelStateSO))] public class ModelStateSOEditor : Editor { public override void OnInspectorGUI() { serializedProperty.Update(); EditorGUILayout.PropertyField(serializedProperty.FindPropertyRelative(transformState.position)); EditorGUILayout.PropertyField(serializedProperty.FindPropertyRelative(transformState.eulerAngles)); EditorGUILayout.PropertyField(serializedProperty.FindPropertyRelative(transformState.uniformScale)); if (GUILayout.Button(Apply to Selected GameObject)) { var so target as ModelStateSO; var go Selection.activeGameObject; if (go ! null) { // 关键记录修改前状态启用 Undo Undo.RecordObject(so, Apply ModelState); so.ApplyTo(go); } } serializedProperty.ApplyModifiedProperties(); } }效果在 Inspector 修改ModelStateSO的eulerAngles点「Apply」后Selection.activeGameObject立即更新且 CtrlZ 可撤销该次应用。6.3 多模型状态管理表用 CSV 存储元数据JSON 存状态对于大型项目如 100 设备把所有状态塞进一个 JSON 文件会拖慢加载。采用分层存储文件名格式用途示例内容models.csvCSV模型元数据索引id,name,glb_path,created_atstate_001.jsonJSONID001 的 ModelState{ transformState: { position: [1,2,3], ... } }C# 加载逻辑public class MultiModelManager : MonoBehaviour { public Dictionarystring, ModelStateSO modelStates new Dictionarystring, ModelStateSO(); public void LoadAllStates() { string csvPath Path.Combine(Application.persistentDataPath, models.csv); var lines File.ReadAllLines(csvPath); foreach (var line in lines.Skip(1)) // skip header { var parts line.Split(,); string id parts[0]; string jsonPath Path.Combine(Application.persistentDataPath, $state_{id}.json); if (File.Exists(jsonPath)) { string json File.ReadAllText(jsonPath); var so ScriptableObject.CreateInstanceModelStateSO(); // 用 JsonUtility 从 json 初始化 so 字段需反射或自定义反序列化 modelStates[id] so; } } } }这是我在风电场数字孪生项目中的落地实践CSV 管理 200 风机模型路径每个state_xxx.json平均 2KB加载 200 个状态耗时 150ms。比单个 400KB JSON 快 3 倍且支持热更新单个模型状态。最后说一句血泪教训永远在Awake()或Start()中初始化GltfImport不要在Update()里反复 new —— glTFast 内部有缓存但频繁实例化会泄漏UnityWebRequest。我曾因此在 iOS 上触发内存警告App 被系统杀掉。希望帮到你。本文还有配套的精品资源点击获取