
简介一套 Unity3d C# 源码工程实现运行时模型文件的导入、编辑与保存面向需要动态加载外部模型并精准控制其位置、旋转、缩放及碰撞体属性的开发者。资源整合运行时导入与编辑器内编辑保存两条主线既支持打包前预置模型的编辑保存也支持运行时导入的新模型对象进行同样操作并通过独立属性输入面板对模型和碰撞体进行数值精准控制。工程共包含1042个文件核心以C#脚本、DLL库、Unity场景资源、FBX模型、Shader材质为主压缩包体积29.25MB目录结构清晰完整便于直接定位到导入、编辑、保存等模块。已有140人浏览学习。实现上结合RuntimeTransformGizmos插件提供可视化拖拽操作外部模型文件可复制到程序目录并加载进场景自动加入碰撞体成为可编辑对象编辑结果自动保存程序启动时会根据保存的对象与文件信息自动加载并恢复属性适合中高级Unity开发者研究或嵌入到动态场景编辑项目中。1. 运行时模型文件导入为什么编辑器之外还需要这条链路发布后的 Unity 应用里玩家或业务人员丢进来一个 OBJ/FBX程序不重启就能把它变成场景里的可编辑对象拖拽位置、旋转、缩放再给模型配上碰撞体最后把状态写进存档。这是典型的 Unity3d C# 运行时模型文件导入诉求我把它拆成一条完整链路外部文件解析 → Mesh 构建 → Transform 编辑 → 碰撞体生成 → JSON 存档。你如果正在做模型预览器、AR 换型工具、工业仿真配置台或者用户自定义装修系统这篇文章就是照着能落地的方案。适合两类人一类是能写 C# 但没深入过 Mesh 底层的中级开发者另一类是已经用过 AssetBundle 想摆脱编辑器依赖、直接吃外部文件的熟手。2. 解析 OBJ 并构建 Mesh一套能直接落地的 C# 解析器2.1 选型逻辑为什么不把 AssetBundle 当唯一答案AssetBundle 在 Unity 内是正统方案构建、加载、依赖管理都有成熟接口。但它的前提是内容在打包前就准备好编辑器菜单里构建 AB、标记依赖、上传 CDN。运行时模型文件导入的场景恰恰相反文件是发布之后才出现的来自用户 U 盘、FTP 推送或者 DCC 软件实时导出。AssetBundle 面对这类非预期内容无能为力你必须走文件流解析这条路。FBX 是二进制且格式版本众多自己从零解析成本极高常见做法是引入 Assimp 的 C# 绑定或 TriLib 这类商业库。但 OBJ 是文本格式语法简单稳定自己写解析器大概两三百行就能覆盖绝大多数工业导出件。我的建议是第一版先手写 OBJ 解析器跑通全链路等业务量上来了再替换成 Assimp 也不迟——解析器对上层只暴露TryImport(string path, out GameObject root, out string error)替换成本很低。这样做的另一个好处是你能完全控制顶点构建过程后面调试碰撞体拟合、法线绕序这些玄学问题时知道每一层数据是什么。2.2 顶点、法线、UV 与索引一个能跑的解析核心OBJ 的核心结构是v / vt / vn / f四类行。v是顶点坐标vt是 UV 坐标vn是法线f是面面的索引从 1 开始而不是 C# 的 0 开始还支持负索引相对当前顶点集合末尾倒数。下面这段代码我精简过去掉了错误分支的噪音保留了能跑通的最小骨架using System; using System.Collections.Generic; using System.Globalization; using System.IO; using UnityEngine; public sealed class RuntimeObjImporter { private readonly ListVector3 _verts new ListVector3(); private readonly ListVector2 _uvs new ListVector2(); private readonly ListVector3 _normals new ListVector3(); public bool TryImport(string path, bool flipUvY, out GameObject root, out string error) { root null; error string.Empty; if (!File.Exists(path)) { error 文件不存在: path; return false; } string[] lines; try { lines File.ReadAllLines(path); } catch (Exception e) { error 读取失败: e.Message; return false; } // 三角形索引暂存最后统一写入 Mesh.SetTriangles var triVerts new Listint(); var triUvs new Listint(); var triNormals new Listint(); foreach (var rawLine in lines) { var line rawLine.Trim(); if (line.Length 0 || line[0] #) continue; var parts line.Split(new[] { , \t }, StringSplitOptions.RemoveEmptyEntries); switch (parts[0]) { case v: _verts.Add(new Vector3( float.Parse(parts[1], CultureInfo.InvariantCulture), float.Parse(parts[2], CultureInfo.InvariantCulture), float.Parse(parts[3], CultureInfo.InvariantCulture))); break; case vt: // 很多DCC导出的 OBJ 的 V 方向和 Unity UV 方向相反 _uvs.Add(flipUvY ? new Vector2(float.Parse(parts[1], CultureInfo.InvariantCulture), 1f - float.Parse(parts[2], CultureInfo.InvariantCulture)) : new Vector2(float.Parse(parts[1], CultureInfo.InvariantCulture), float.Parse(parts[2], CultureInfo.InvariantCulture))); break; case vn: _normals.Add(new Vector3( float.Parse(parts[1], CultureInfo.InvariantCulture), float.Parse(parts[2], CultureInfo.InvariantCulture), float.Parse(parts[3], CultureInfo.InvariantCulture))); break; case f: ParseFace(parts, triVerts, triUvs, triNormals); break; } } if (_verts.Count 0 || triVerts.Count 0) { error OBJ 中没有顶点或面数据检查导出时是否勾选了仅导出选中对象。; return false; } var mesh new Mesh { name RuntimeMesh_ Path.GetFileNameWithoutExtension(path) }; mesh.SetVertices(_verts); mesh.SetTriangles(triVerts, 0); if (triUvs.Count triVerts.Count) { var uvArray new Vector2[triVerts.Count]; for (int i 0; i triVerts.Count; i) uvArray[i] _uvs[ResolveIndex(triUvs[i], _uvs.Count)]; mesh.SetUVs(0, uvArray); } if (triNormals.Count triVerts.Count) { var normalArray new Vector3[triVerts.Count]; for (int i 0; i triVerts.Count; i) normalArray[i] _normals[ResolveIndex(triNormals[i], _normals.Count)]; mesh.SetNormals(normalArray); } else { mesh.RecalculateNormals(); } mesh.RecalculateBounds(); root new GameObject(Path.GetFileNameWithoutExtension(path)); root.AddComponentMeshFilter().sharedMesh mesh; root.AddComponentMeshRenderer().sharedMaterial CreateDefaultMaterial(); return true; } private void ParseFace(string[] parts, Listint triVerts, Listint triUvs, Listint triNormals) { // parts[0] 是 f真正的索引从 parts[1] 开始 int count parts.Length - 1; if (count 3) return; var faceVerts new int[count]; var faceUvs new int[count]; var faceNormals new int[count]; for (int i 0; i count; i) { // 支持 f 1 2 3, f 1/1/1 2/2/2 3/3/3, f 1//1 2//2 3//3 var sub parts[i 1].Split(/); faceVerts[i] ResolveIndex(int.Parse(sub[0]), _verts.Count); if (sub.Length 1 sub[1].Length 0) faceUvs[i] ResolveIndex(int.Parse(sub[1]), _uvs.Count); if (sub.Length 2 sub[2].Length 0) faceNormals[i] ResolveIndex(int.Parse(sub[2]), _normals.Count); } // 多边形三角化以第一个顶点为扇心 for (int i 1; i count - 1; i) { triVerts.Add(faceVerts[0]); triVerts.Add(faceVerts[i]); triVerts.Add(faceVerts[i 1]); triUvs.Add(faceUvs[0]); triUvs.Add(faceUvs[i]); triUvs.Add(faceUvs[i 1]); triNormals.Add(faceNormals[0]); triNormals.Add(faceNormals[i]); triNormals.Add(faceNormals[i 1]); } } private int ResolveIndex(int index, int collectionCount) { // OBJ 正索引从 1 开始负索引相对当前末尾 return index 0 ? index - 1 : collectionCount index; } private Material CreateDefaultMaterial() { var shader Shader.Find(Standard); // URP 项目这里要改成 Shader.Find(Universal Render Pipeline/Lit) if (shader null) shader Shader.Find(Universal Render Pipeline/Lit); return new Material(shader); } }代码逻辑关键点有三个。第一所有 float 解析都带CultureInfo.InvariantCulture否则在德语、法语系统上小数点会被解析成逗号导致坐标全部错乱这是运行时导入最容易翻车的隐性 bug。第二f面解析是扇面三角化OBJ 的f行不一定是三角形四边形、五边形都要展开用首顶点做扇心是最稳的通用做法。第三法线优先级OBJ 自带vn就用没有就让 UnityRecalculateNormals但注意RecalculateNormals对尖锐边缘会糊掉导入工业模型时建议优先信任文件里的vn。2.3 从 Mesh 到 GameObject装配渲染与材质解析器返回的是带 MeshFilter 和 MeshRenderer 的空壳 GameObject材质用了最简单粗暴的默认 Standard。真实项目里这个环节要做三件事贴图加载、材质参数设置、模型层级整理。public static void ApplyTexture(GameObject target, string texturePath) { if (string.IsNullOrEmpty(texturePath) || !File.Exists(texturePath)) return; var tex new Texture2D(2, 2, TextureFormat.RGBA32, false); var bytes File.ReadAllBytes(texturePath); if (tex.LoadImage(bytes)) { tex.wrapMode TextureWrapMode.Clamp; tex.filterMode FilterMode.Bilinear; var mat target.GetComponentMeshRenderer().sharedMaterial; mat.mainTexture tex; } }贴图加载用Texture2D.LoadImage它内部能识别 PNG/JPG不需要手动解码。wrapMode建议设 Clamp 而不是 Repeat工业模型贴图边界溢出时 Repeat 会拉出明显的条纹。filterMode设 Bilinear 够用除非你做高精度产品渲染才需要 Trilinear。注意 LoadImage 的主要开销在解码本身大图会在主线程卡几十毫秒后续可以丢到线程里解析完再回主线程创建 Texture2D第一版不必过度设计。层级整理的原则是导入的 GameObject 作为根节点根节点只挂 Transform所有渲染和碰撞组件挂到子节点。这样后面编辑位置旋转缩放时对根节点操作子节点内部的 MeshFilter 和 Collider 不做改动避免碰撞体中心和缩放跟着一起变。3. 位置、旋转、缩放的运行时编辑从 UI 到 JSON 存档3.1 UI 映射Slider 和 InputField 怎么驱动 Transform运行时编辑位置旋转缩放最直接的做法是用面板上的三个 Slider 分别绑定 X/Y/Z。位置和缩放的映射很直白旋转要绕一下UI 上给用户看欧拉角内部存 Quaternion。public sealed class TransformPanel : MonoBehaviour { public Slider posXSlider, posYSlider, posZSlider; public Slider rotXSlider, rotYSlider, rotZSlider; public Slider scaleSlider; // 等比缩放防止非等比缩放搞乱碰撞体 private Transform _target; public void Bind(Transform target) { _target target; posXSlider.onValueChanged.AddListener(v SetLocalPosition(0, v)); posYSlider.onValueChanged.AddListener(v SetLocalPosition(1, v)); posZSlider.onValueChanged.AddListener(v SetLocalPosition(2, v)); rotYSlider.onValueChanged.AddListener(v SetRotation(1, v)); rotXSlider.onValueChanged.AddListener(v SetRotation(0, v)); rotZSlider.onValueChanged.AddListener(v SetRotation(2, v)); scaleSlider.onValueChanged.AddListener(v { _target.localScale Vector3.one * v; }); } private void SetLocalPosition(int axis, float value) { var p _target.localPosition; if (axis 0) p.x value; else if (axis 1) p.y value; else p.z value; _target.localPosition p; } private void SetRotation(int axis, float eulerAngle) { var e _target.localEulerAngles; if (axis 0) e.x eulerAngle; else if (axis 1) e.y eulerAngle; else e.z eulerAngle; _target.localRotation Quaternion.Euler(e); } }这段代码刻意避开了两个坑。第一缩放用等比而不是 XYZ 独立 Slider因为非等比缩放会让 MeshCollider 的烘焙结果和实际渲染不一致后文碰撞体章节会细说。第二旋转用localEulerAngles读出来改完再写回localRotation而不是直接给 rotation 赋值因为 Quaternion 的四个分量用户看不懂也没法调。Slider 范围建议位置设 ±10、旋转设 0~360 或 -180~180缩放设 0.1~10这三个范围覆盖绝大多数装配场景。3.2 数据模型用 TransformData 而不是 GameObject 直接存运行时编辑完要保存第一直觉是“把 GameObject 序列化”。Unity 的 JsonUtility 做不到这一点GameObject 不是可序列化类型。正确做法是定义纯数据类把需要的字段全部显式列出来[Serializable] public sealed class SceneRuntimeData { public ListSceneObjectData objects new ListSceneObjectData(); } [Serializable] public sealed class SceneObjectData { public string id; // 实例唯一ID恢复时用于查找父节点 public string modelName; // 模型文件名用于重新导入 public string meshPath; // 模型文件的完整路径或相对路径 public Vector3 position; // 本地坐标JsonUtility 原生支持 public Quaternion rotation; // 四元数不是欧拉角 public Vector3 localScale Vector3.one; public ColliderData collider; // 碰撞体参数 }这里的关键决策是保存本地坐标而不是世界坐标。如果保存世界坐标读回来时一旦父节点位置不同模型会整体偏移保存本地坐标配合父节点 ID恢复时先把父节点装好再套本地值结果永远正确。旋转保存 Quaternion 而不是欧拉角因为欧拉角在跨版本引擎读回时如果有万向锁边界旋转顺序会造成莫名其妙的反转Quaternion 是引擎的原始表示写进 JSON 就是{x,y,z,w}四个数字没有任何歧义。保存和读取各只有几行public static void SaveToFile(SceneRuntimeData data, string fileName) { var path Path.Combine(Application.persistentDataPath, fileName); var json JsonUtility.ToJson(data, true); // true 格式化缩进方便人工检查 File.WriteAllText(path, json); } public static SceneRuntimeData LoadFromFile(string fileName) { var path Path.Combine(Application.persistentDataPath, fileName); if (!File.Exists(path)) return null; var json File.ReadAllText(path); return JsonUtility.FromJsonSceneRuntimeData(json); }3.3 存档路径选型与写回流程Application.persistentDataPath在 Windows 是C:/Users/用户名/AppData/LocalLow/公司名/产品名在 Android 是应用私有目录在 macOS 是~/Library/Application Support/...。这个目录可写、跨平台、系统不会乱清理是运行时存档的唯一正确选择。Resources和StreamingAssets都只读不能用来写存档。写回流程是先LoadFromFile再遍历objects列表对每个SceneObjectData调用导入器重新加载模型、设置 Transform、重建碰撞体完成一次完整的存档恢复闭环。恢复代码里我一般会在根节点上加一个RuntimeModelRoot组件作为标记里面存id和meshPath。这样下次保存时不需要重新查字典直接FindObjectsByTypeRuntimeModelRoot()就能拿到全部可编辑对象迭代导出当前场景状态。4. 碰撞体信息自动选型、生成与回写4.1 三种碰撞策略与适用边界MeshCollider 精度最高把 Mesh 的三角形直接给物理引擎碰撞面和渲染面完全一致。代价是性能非凸 MeshCollider 的碰撞计算开销极大而且两个非凸 MeshCollider 之间不会互相碰撞只适合完全静态的装饰物。BoxCollider 性能最好精度最差旋转后的模型用 AABB 包围盒会明显偏大。复合碰撞体是折中方案用多个 Box 或 Sphere 拼出近似外形性能可控精度也能接受。运行时导入的模型来自外部文件质量参差不齐我不能假定它是低模还是高模。所以选型逻辑必须是动态的顶点数少、结构简单的模型直接上 MeshCollider顶点数多的复杂模型降级成 BoxCollider 或复合球。判断阈值我一般用 1024 个三角形超过就放弃 MeshCollider因为运行时擦除一个十几万面的模型碰撞体足够让物理帧时间翻倍。碰撞体类型精度性能适用模型动态移动MeshCollider 凸中中小体积复杂外形可以MeshCollider 非凸高差静态装饰物不行BoxCollider低极好规则外形可以复合 Box/Sphere中好中大型设备可以4.2 自动生成碰撞体一段带阈值的代码public static Collider AddBestFitCollider(GameObject go) { var mf go.GetComponentMeshFilter(); if (mf null || mf.sharedMesh null) return null; var mesh mf.sharedMesh; int triangleCount mesh.triangles.Length / 3; // 小模型直接用 MeshCollider且能活动就强制凸 if (triangleCount 1024) { var mc go.AddComponentMeshCollider(); mc.sharedMesh mesh; // 需要移动或旋转的物体凸碰撞体是硬性要求 mc.convex triangleCount 512; return mc; } // 大模型先用 BoxCollider 兜底避免物理引擎直接崩 var bounds mesh.bounds; var box go.AddComponentBoxCollider(); box.center bounds.center; box.size bounds.size; return box; }注意这段代码有个隐含假设碰撞体挂在和 MeshFilter 同一个 GameObject 上。如果根节点上有缩放而子节点上有 Meshmesh.bounds是模型空间的数据Collider 会跟随挂载点的 Transform 做变换所以碰撞体必须和 MeshFilter 同节点不能挂到父节点。这个约定最好写进项目规范否则后期排查碰撞体位置偏移会非常痛苦。mc.convex的阈值 512 是经验值凸包计算的开销随顶点数上升512 以内物理引擎能扛住再大就需要在导入时用简化工具降面。如果模型必须保持高精度碰撞且要移动正确的做法不是直接开 Convex而是单独导出一个低模用于碰撞体渲染高模和碰撞低模分离是游戏工业的标准做法运行时导入也可以这么干解析 OBJ 时同时读取同目录一个_col.obj找不到就退回自动生成。4.3 碰撞体信息序列化与回写Unity 的 Collider 组件本身没法直接存 JSONJsonUtility也不认它。所以存档里只保存参数的纯数据副本读回来时按类型重新创建。这里我用一个显式 type 字符串而不是继承结构因为 JsonUtility 不支持多态反序列化。[Serializable] public sealed class ColliderData { public string type; // BoxCollider / SphereCollider / CapsuleCollider / MeshCollider public Vector3 center; public Vector3 size; // Box 专用 public float radius; // Sphere/Capsule 专用 public float height; // Capsule 专用 public int direction; // Capsule 轴向0X, 1Y, 2Z public bool isTrigger; public bool convex; // MeshCollider 专用 }从现存 Collider 导出参数public static ColliderData ExportFrom(Collider c) { var data new ColliderData { type c.GetType().Name }; switch (c) { case BoxCollider box: data.center box.center; data.size box.size; break; case SphereCollider sphere: data.center sphere.center; data.radius sphere.radius; break; case CapsuleCollider capsule: data.center capsule.center; data.radius capsule.radius; data.height capsule.height; data.direction capsule.direction; break; case MeshCollider meshCollider: data.convex meshCollider.convex; break; } data.isTrigger c.isTrigger; return data; }回写时反过来按 type 字段生成对应组件。MeshCollider 回写后要重新把sharedMesh指向 MeshFilter 上的网格这个引用不会进 JSON恢复流程里必须显式赋值容易漏。回写后建议调用一次Physics.SyncTransforms()否则刚创建完的碰撞体在同一帧内查询位置可能拿到旧数据。4.4 为什么不把 Mesh 数据本身存进碰撞体存档有同事踩过这个坑把 MeshCollider 涉及的三角形顶点全部序列化进存档美其名曰“完整还原”。结果一个几万面的模型存档膨胀到十几 MB加载时还要重新构建 Mesh耗时以秒计。正确姿势是存档只保存模型文件路径和 TransformMesh 本体永远通过重新解析 OBJ 生成。文件路径比 Mesh 二进制可靠得多——原始文件可以外部备份、可以版本管理、可以热更新替换。这条原则对所有运行时资源都一样能存引用就不存数据能重新生成就不持久化。5. 运行时模型导入的 5 个踩坑记录现象、原因与排查5.1 模型导入后朝向不对Z 轴反了像照镜子现象同一个 OBJ 在 Blender 里看着正常导入 Unity 后模型左右颠倒文字贴图镜像翻转。原因OBJ 格式出身于右手坐标系X 向右Y 向上Z 向外Unity 是左手坐标系X 向右Y 向上Z 向里。坐标系的镜像差异会导致 Z 轴方向相反整个模型呈镜像状。解决解析顶点时对 Z 取反z -z同时将三角形绕序反转把索引顺序从 0-1-2 改成 0-2-1因为镜像翻转会让正面变成背面。注意不要只取反 Z 不反转绕序那样模型会变成透明壳。这个开关建议做成导入参数因为从 3ds Max 导出的 OBJ 有时已经是左手系再取反反而错。5.2 模型显示为半透明或直接消失旋转视角才出现现象导入的网格在 Scene 视图看不到转到一个特定角度能看到内部面像玻璃一样。原因三角形绕序不对。Unity 默认启用背面剔除正面要求顶点按逆时针顺序排列。部分 DCC 工具导出 OBJ 时用的是顺时针绕序Unity 判断为背面直接丢弃。解决在ParseFace三角化后检查一次整体绕序——算一个包围盒对角线的叉积方向如果发现法线和顶点顺序矛盾就把所有三角形索引倒序。更快的排查法在TryImport里加一个reverseWinding参数导错时切一下开关就行不用改代码重启。5.3 保存的 JSON 读回来模型位置和之前完全对不上现象编辑完保存重启应用加载存档模型出现在完全不同的地方而且层级复杂时偏差更大。原因存档里保存了世界坐标但恢复时父节点还没构建完成。Unity 加载 GameObject 是先创建父再创建子子节点设置世界坐标的瞬间父节点 Transform 是默认值等父节点就位后子节点又被父节点相对偏移了一遍。解决存档一律存本地坐标localPosition、localRotation、localScale恢复时先构建完整父链再按从父到子的顺序设置 Transform。如果业务确实需要世界坐标比如跨场景摆放那也要在父节点层级稳定后再转一次。5.4 MeshCollider 挂上之后物理帧时间从 2ms 涨到 30ms现象导入一个高模设备添加 MeshCollider 后整个场景变卡物体碰撞出现穿透严重时 Editor 直接擦除。原因非凸 MeshCollider 的碰撞检测是逐三角形做的几万面的网格会让物理引擎的 Broadphase 直接爆炸。更糟的是非凸碰撞体之间不能互相碰撞两个高模设备叠在一起时直接穿模。解决按 4.2 节的策略三角形数量超过 1024 就退化成 BoxCollider 或复合碰撞体。如果业务要求高精度必须单独准备低模碰撞体运行时用同一个文件路径加_col后缀去加载碰撞专用 OBJ。上线前用 Profiler 的 Physics 模块扫一遍看到Collider.Process耗时占比超过 20% 就要立刻降级。5.5 大 OBJ 解析时主线程卡死界面完全无响应现象用户导入一个几十万面的 OBJ点击导入按钮后应用完全冻结几秒到十几秒后才恢复。原因File.ReadAllLines一次性把整个文件读进内存加上逐行字符串分割和 float 解析几十万行的纯文本操作全在主线程执行。内存峰值可能到几百 MB解析耗时 5 秒以上用户感觉就是崩溃。解决第一版先加一个分帧协程每帧解析 5000 行把TryImport改造成IEnumerator TryImportAsync配合一个进度条 UI。后续再优化可以把解析搬到后台线程但 Mesh 构建和 GameObject 创建必须回到主线程。另外一个容易忽略的点File.ReadAllLines可以用StreamReader.ReadLine()代替内存峰值能降一半以上是改动最小收益最大的优化。6. 验证闭环自动化冒烟测试与一次翻车教训运行时导入功能太容易“看起来没问题”所以我强制自己写了一条自动化冒烟测试用 Unity Test Framework 跑通导入→编辑→保存→恢复的完整闭环[Test] public void ImportEditSaveRestore_RoundTrip() { // 手工构造一个四边形 OBJ绕开外部文件依赖 string testObj Path.Combine(Application.temporaryCachePath, quad_test.obj); File.WriteAllText(testObj, v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\n vt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\n f 1/1 2/2 3/3\nf 1/1 3/3 4/4\n); var importer new RuntimeObjImporter(); Assert.IsTrue(importer.TryImport(testObj, true, out var go, out var error), error); // 编辑 Transform go.transform.localPosition new Vector3(2f, 3f, 4f); go.transform.localRotation Quaternion.Euler(30f, 45f, 60f); go.transform.localScale Vector3.one * 1.5f; // 生成碰撞体并导出存档 AddBestFitCollider(go); var data new SceneRuntimeData(); data.objects.Add(new SceneObjectData { id test_001, meshPath testObj, position go.transform.localPosition, rotation go.transform.localRotation, localScale go.transform.localScale, collider ExportFrom(go.GetComponentCollider()) }); SaveToFile(data, smoke_test.json); // 恢复重新导入、重设 Transform、重建碰撞体 var loaded LoadFromFile(smoke_test.json); var restored loaded.objects[0]; Assert.IsTrue(importer.TryImport(restored.meshPath, true, out var restoredGo, out error), error); restoredGo.transform.localPosition restored.position; restoredGo.transform.localRotation restored.rotation; restoredGo.transform.localScale restored.localScale; AddBestFitCollider(restoredGo); // 对比误差在千分之一以内 Assert.That(Vector3.Distance(go.transform.localPosition, restoredGo.transform.localPosition), Is.LessThan(0.001f)); Assert.That(Quaternion.Angle(go.transform.localRotation, restoredGo.transform.localRotation), Is.LessThan(0.01f)); Object.DestroyImmediate(go); Object.DestroyImmediate(restoredGo); }这条测试我吃了不少亏才稳定下来说一个真实教训最早版本的保存逻辑把rotation存成欧拉角 Vector3测试里旋转设置的是 30/45/60恢复后再转回欧拉角变成 29.9/44.9/60.1看似通过。但把角度改成 90/90/90 之后恢复结果变成了 90/89.99/90.01误差没超实际渲染却有轻微抖动。原因是欧拉角转四元数再转回欧拉角的过程对 90 度边界极其敏感几个浮点误差就会被放大。从那以后我所有存档统一存 Quaternion欧拉角只出现在 UI 层这一条写进了项目编码规范。最后再提醒一个习惯性的自测动作每次改完解析器或碰撞体逻辑把测试里的断言误差从 0.01 收紧到 0.001 再跑一遍。数值误差如果连千分之一都过不了说明代码里有真实的精度问题被放过了。这个方案踩坑最多、返工最频繁的阶段就是我在碰撞体序列化上钻牛角尖的那两周现在整套链路稳定跑在生产环境的模型预览工具上。运行时模型文件导入这件事本质是把进度条、数据模型、物理引擎三个部分各管好边界希望帮到你。本文还有配套的精品资源点击获取