简介:面向Unity开发者的运行时模型处理源码工程,解决程序运行过程中动态导入外部模型文件,并对其位置、旋转、缩放和碰撞体信息进行编辑、保存的问题。工程整合了第三方模型导入工具与场景对象操作控件,同时提供独立的属性输入面板,支持通过数值精确调整模型和碰撞体的各项参数,适合有运行时资源管理需求的中高级开发人员。压缩包为7z格式,共1042个文件,以C#脚本、插件动态库、着色器、三维模型、场景配置、材质贴图及说明文档等类型为主,整体约29.25MB;脚本与动态库覆盖核心导入、编辑和保存逻辑,模型、材质及着色器可供直接参考或替换,已有142人学习/下载。源码工程完整涵盖导入、编辑、保存与重启加载流程:外部模型文件复制到程序目录后可载入场景,点选对象即可通过控件拖拽调整位置、旋转和缩放,也能在属性面板中微调数值,碰撞体信息同样支持编辑和持久化,程序下次启动时会根据保存的数据恢复模型和属性。项目目录结构清晰,便于按模块复用,适合直接集成到实际Unity项目中二次开发。
1. 运行时模型导入与编辑:把“编辑器依赖”从工具链里拆掉
一个三维场景编辑工具,如果在开发期靠 Unity 编辑器手动拖模型、调参数,那它只能在开发机上跑。可一旦产品要交付给运营人员,让他们在客户端里直接换模型、改摆放、调大小、存配置,就必须把“导入 — 编辑 — 保存”整条链路搬进运行时。这是我做模拟项目X(一个轻量级场景配置工具)时最痛的需求:用户不想碰编辑器工程,他们只想要一个面板,能在程序里加载外部模型文件,拖动调整位置旋转缩放,再一键把调整结果连同碰撞体信息存下来。这套能力拆出来就是本文要解决的四个核心点:运行时文件导入、Transform 编辑、碰撞体配置、数据序列化。适合三类读者:做场景编辑工具的人、做模型预览与批处理的人、以及想在 Unity 工程里集成动态资源加载能力的团队。下面直接进入实现路径。
2. 运行时模型文件导入:AssetBundle 与 OBJ 解析两条主路
2.1 为什么编辑器导入在运行时场景下失灵
Unity 编辑器里的“导入”本质上是一套资源管线:FBX 经过模型导入器处理,生成 Unity 内置的 Mesh 资源、Avatar 骨骼信息、材质映射关系,再打包进工程。这套流程在编辑器里很顺手,但它依赖编辑器上下文,进程跑起来之后没有导入窗口、没有 Inspector,AssetDatabase 也无法在构建后的产物里正常使用。运行时能稳定读取的模型数据来源,常见做法是两条:一是把模型预先放进 AssetBundle 包,运行时用 UnityWebRequest 加载;二是解析通用模型文件格式(OBJ、PLY 这类纯文本/二进制格式),自己写解析器,直接构造 Mesh 对象。第一条路工程化程度高,适合正式交付;第二条路灵活,适合编辑器工具、模型预览、热更新场景。两者的共同前提是:Mesh 必须在运行时被重新构建或实例化,不能依赖编辑器生成的序列化引用。
2.2 用 AssetBundle 做运行时加载的最小 C# 流程
AssetBundle 是 Unity 官方提供的资源打包方案,做法是把模型和它依赖的材质、贴图一起打成 .bundle 文件,存到本地或远程服务器。运行时加载的核心代码只有几个调用,但每一步都有参数决策要做。下面是我在项目里用的最小实现:
using UnityEngine; using UnityEngine.Networking; using System.Collections; public class RuntimeAssetBundleLoader : MonoBehaviour { public Transform targetParent; // 加载后挂载到这个节点下 /// <summary> /// 从本地路径或 URL 加载 AssetBundle 并实例化模型 /// </summary> /// <param name="bundlePath">bundle 文件路径,比如 Application.streamingAssetsPath + "/models.bundle"</param> /// <param name="assetName">bundle 内的资源名称,比如 "Building_A"</param> public IEnumerator LoadAndInstantiate(string bundlePath, string assetName) { // 1. 发起加载请求,注意用 UnityWebRequestAssetBundle 而不是普通 UnityWebRequest UnityWebRequest request = UnityWebRequestAssetBundle.GetAssetBundle(bundlePath); yield return request.SendWebRequest(); // 2. 结果校验:网络错误、HTTP 错误、本地文件不存在都会走到这里 if (request.result != UnityWebRequest.Result.Success) { Debug.LogError($"[BundleLoader] 加载失败: {request.error}, path={bundlePath}"); yield break; } // 3. 拿到 AssetBundle 对象,GetContent 替代了旧的 GetDownloadHandlerAssetBundle AssetBundle bundle = DownloadHandlerAssetBundle.GetContent(request); if (bundle == null) { Debug.LogError("[BundleLoader] bundle 内容为空"); yield break; } // 4. 加载资源。模型通常以 GameObject 形式存在,也可以直接加载 Mesh/材质 AssetBundleRequest assetRequest = bundle.LoadAssetAsync<GameObject>(assetName); yield return assetRequest; GameObject modelPrefab = assetRequest.asset as GameObject; if (modelPrefab == null) { // 这一步经常因为资源名不对而失败,建议先 GetAllAssetNames() 打印一遍 Debug.LogError($"[BundleLoader] 未找到资源 {assetName},现有: {string.Join(", ", bundle.GetAllAssetNames())}"); bundle.Unload(false); yield break; } // 5. 实例化并挂到目标父节点下 GameObject instance = Instantiate(modelPrefab, targetParent); instance.transform.localPosition = Vector3.zero; instance.transform.localRotation = Quaternion.identity; instance.transform.localScale = Vector3.one; Debug.Log($"[BundleLoader] 模型 {assetName} 加载完成"); // 6. 注意:不要立即 Unload!模型材质和网格还在使用中 // 在所有实例销毁后,再调用 bundle.Unload(false) 释放资源 } }参数说明:bundlePath推荐放在 StreamingAssets 或持久化目录,避免打包后路径失效;assetName不一定是文件名,是打包时设置的assetBundleName对应的资源路径。逻辑上第 4 步使用异步加载是必须的——大型模型同步加载会卡主线程几十毫秒,在编辑器工具里可能感受不明显,但正式产品里用户拖入一个 50MB 的模型,界面冻结超过 1 秒就会被认为是闪退。第 6 步的 Unload 时机是个经典坑:有人加载完立刻 Unload,导致模型材质变紫,这个问题在后面的避坑章节会专门展开。
2.3 自己解析 OBJ:坐标轴与法线重算
AssetBundle 的问题在于打包流程依赖编辑器,如果目标是让用户直接选择硬盘上的 .obj/.fbx 文件,就得换方案。OBJ 是纯文本格式,结构简单,只需处理 v(顶点)、vt(UV)、vn(法线)、f(面索引)四类记录。写解析器的内核是逐行读取,按空格分割 token 并填充顶点数组。真正的难点在别处:OBJ 坐标系是右手系(Y 轴向上对应 Z 轴向外),Unity 是左手系(Y 向上、Z 向前/Z 指向摄像机),模型导入后往往需要绕 X 轴旋转 -90 度,否则模型是“躺”在地上的。第二个坑是法线:很多 OBJ 文件本身不带 vn 记录,渲染会出现黑色模型,需要按三角形面法线加权重算。第三个坑是 f 记录里的索引可能是负数(相对索引),也要处理。解析结束后,把 Vector3 数组直接 assign 给 Mesh.vertices,再手动设置 triangle、uv、normals,调用mesh.RecalculateNormals()。完整解析器要两百行左右,但核心骨架如下:
List<Vector3> verts = new List<Vector3>(); List<Vector2> uvs = new List<Vector2>(); List<Vector3> normals = new List<Vector3>(); List<int> tris = new List<int>(); foreach (string rawLine in File.ReadLines(filePath)) { string line = rawLine.Trim(); if (line.StartsWith("v ")) // 顶点位置 { string[] parts = line.Split(' '); verts.Add(new Vector3( float.Parse(parts[1]), float.Parse(parts[2]), float.Parse(parts[3]) )); } else if (line.StartsWith("vt ")) // 纹理坐标 { string[] parts = line.Split(' '); uvs.Add(new Vector2(float.Parse(parts[1]), float.Parse(parts[2]))); } else if (line.StartsWith("vn ")) // 法线 { string[] parts = line.Split(' '); normals.Add(new Vector3( float.Parse(parts[1]), float.Parse(parts[2]), float.Parse(parts[3]) )); } else if (line.StartsWith("f ")) // 面索引 { string[] parts = line.Split(' '); // OBJ 面格式: f 顶点索引/UV索引/法线索引,索引从 1 开始 for (int i = 1; i < parts.Length; i++) { string[] idx = parts[i].Split('/'); tris.Add(int.Parse(idx[0]) - 1); // 转为从 0 开始 } } } Mesh mesh = new Mesh(); mesh.vertices = verts.ToArray(); mesh.uv = uvs.ToArray(); mesh.triangles = tris.ToArray(); mesh.RecalculateNormals(); // 自动补齐缺失的法线 mesh.RecalculateBounds();这段代码在几百 MB 的大型 OBJ 上会非常慢,因为 List 扩容和 File.ReadLines 逐行读取都存在性能瓶颈。我一般在解析前先File.ReadAllLines按行读取,再用预分配数组或List<Vector3>(capacity)指定容量,能快 40% 左右。还要注意一个边界坑:OBJ 的面可能不是三角形,比如四边形f 1 2 3 4,此时需要拆成两个三角形(1-2-3 和 1-3-4),直接按索引填充会把四边形当成三角扇形,出现网格撕裂。
2.4 导入后的层级清理与 Mesh 复用
外部模型导入运行时后,常常带有多余的根节点、空 GameObject、分离的材质子物体。不做清理直接编辑位置,用户会困惑“为什么转的是空节点,模型没动”。我习惯在导入后做一次冒泡式清理:遍历子物体,删除名称为“default”“Material”或网格为空的节点,把剩下的子物体统一到同一层级。这一步看起来不关键,但它直接影响后续 Transform 编辑的坐标基准——层级里多一层偏移,保存下来的位置数据就和显示的位置对不上。另一个注意是 Mesh 复用:同一模型被多次实例化时,Mesh 数据会共享,任何对Mesh.vertices的写入都会影响所有实例。编辑功能只动 Transform,不动 Mesh,通常不会有冲突;但如果后续加了顶点级编辑,记得先Instantiate(mesh)再修改。
3. 位置、旋转、缩放的运行时编辑:从面板到 Transform 的数据管道
3.1 编辑器的数据隔离:内存对象和显示对象分开
做运行时编辑时,很多人的第一反应是“选中物体,改它的 Transform”。这在简单场景可行,但一旦涉及撤销重做、配置保存前校验、多人同时编辑,直接改 Transform 会带来数据污染。更稳的结构是三层:数据层(ModelData:记录位置旋转缩放的数值)、视图层(实际场景里的 GameObject)、控制器层(UI 面板和拖拽交互)。用户操作 UI 时,先改数据层,再通过控制器把值同步给 Transform;保存时直接序列化数据层,不和渲染对象耦合。这样做的收益非常直接:撤销重做只需要备份数据层的值,不需要记录 Transform 快照;保存前可以批量校验数值范围,不用担心场景里残留的半修改状态。我在模拟项目X里就是按这个结构做的,后续加 Undo 系统时几乎没有改动数据层。
3.2 数值输入与拖拽交互两种实现
运行时编辑面板至少要有两种输入方式:数值输入和拖拽操作。数值输入逻辑简单,用 InputField 绑定监听,用户输入后解析 float 并写入数据层:
using UnityEngine; using UnityEngine.UI; using TMPro; public class TransformPanelController : MonoBehaviour { public GameObject targetObject; // 当前选中的模型 public TMP_InputField posXField; // X 坐标输入框 public TMP_InputField scaleField; // 同步缩放输入框 private Vector3 _cachedPosition; private Vector3 _cachedScale; private void OnEnable() { if (targetObject == null) return; // 打开面板时读取当前值,作为输入框初始值 _cachedPosition = targetObject.transform.localPosition; _cachedScale = targetObject.transform.localScale; posXField.text = _cachedPosition.x.ToString("F2"); scaleField.text = _cachedScale.x.ToString("F2"); } public void OnPositionXChanged(string value) { if (!float.TryParse(value, out float x)) return; Vector3 newPos = new Vector3(x, _cachedPosition.y, _cachedPosition.z); targetObject.transform.localPosition = newPos; _cachedPosition = newPos; // 注意:这里没做数值范围校验,业务上需要限制的话在解析后判断 } public void OnScaleChanged(string value) { if (!float.TryParse(value, out float s)) return; s = Mathf.Max(0.01f, s); // 防止 0 缩放和负缩放 targetObject.transform.localScale = new Vector3(s, s, s); _cachedScale = new Vector3(s, s, s); } }这段代码里有个隐蔽习惯:每次写入都同步更新_cachedPosition,为后续保存逻辑提供一致的数据快照。参数上值得单独说三个:一是用Mathf.Max限制最小缩放,否则负缩放会翻转法线、0 缩放会让光消失;二是多输入框联动时不要把每次变更直接刷新其它输入框,否则会出现“输一位数字就被重置”的鬼影问题;三是局部坐标和世界坐标的选择——面板里我统一用localPosition和localScale,因为保存时记录的是物体间的相对关系,而不是绝对位置,否则父节点移动后所有子物体的保存数据全部失效。
拖拽操作比数值输入复杂,因为运行时没有编辑器里的 Gizmo,需要自己实现拖拽轴。常见做法是用 Physics 射线检测,在物体附近做三个轴方向的半透明线段,鼠标点击到某条线段后,锁定该轴,把鼠标的屏幕空间位移换算成对象在该轴上的局部位移。难点是“换算比例”——鼠标移动 100 像素对应物体移动多少米?需要在Camera.ScreenPointToRay的射线方向和锁定轴方向之间做投影计算。我提供一个简化版的轴拖拽计算,核心就是用射线与目标平面求交点,取交点在该轴上的分量作为增量:
public class AxisDragController : MonoBehaviour { public Transform target; public Camera cam; public Transform xAxisHandle; // 场景中的拖拽轴手柄 private bool _isDragging; private Plane _dragPlane; private Vector3 _dragOffset; public void StartDrag() { _isDragging = true; // 拖拽平面:通过模型中心,法线朝向摄像机 _dragPlane = new Plane(cam.transform.forward, target.position); } public void UpdateDrag(Vector2 mousePosition) { if (!_isDragging) return; Ray ray = cam.ScreenPointToRay(mousePosition); if (_dragPlane.Raycast(ray, out float enter)) { Vector3 hitPoint = ray.GetPoint(enter); Vector3 targetPos = target.position; // 只取 X 轴分量,另外两个轴保持原值 targetPos.x = hitPoint.x + _dragOffset.x; target.position = targetPos; } } public void EndDrag() => _isDragging = false; }这段代码的缺陷是忽略了相机的俯仰角——摄像机从侧面看时,屏幕上的水平移动在 X 轴上会有明显衰减,专业工具需要把平面法线和轴方向一起做投影矩阵换算。对大多数场景编辑器来说,这个方案的交互已经够用,重点是它演示了“射线与平面求交”这个核心数学步骤。
3.3 旋转的坑:欧拉角 vs 四元数
位置和缩放是线性操作,旋转不一样。Inspector 里显示的旋转是欧拉角,而 Transform 内部存的是四元数。如果在编辑面板直接读欧拉角、改其中一个轴、再写回去,会遇到万向锁:物体 pitch 到 ±90 度时,yaw 和 roll 会被耦合,一个轴的调整会连带改变另一个轴。运行时的编辑面板要想稳定,旋转交互必须用四元数增量方式:每次拖拽或输入,构造一个相对当前姿态的四元数增量,用乘法叠加。比如水平拖拽绕 Y 轴旋转,每次增量是Quaternion.Euler(0, deltaDegrees, 0),然后target.localRotation = target.localRotation * deltaRotation,这样任何姿态下旋转行为都可预测。数值输入框里的显示值,可以在保存时转成欧拉角,但编辑过程不要再从欧拉角出发。这句话值得直接在注释里写出来,因为几乎每个做运行时编辑的人都会在这里翻一次车。
4. 碰撞体信息的生成、编辑与序列化
4.1 模型加载后碰撞体生成策略
模型加载进来之后,如果没有碰撞体,就无法参与物理交互。常见做法是加载后根据 Renderer 的包围盒自动生成 BoxCollider,这是最简单也最稳的方案。实现代码极短:
using UnityEngine; public static class ColliderAutoGenerator { public static BoxCollider GenerateBox(GameObject modelRoot) { Renderer[] renderers = modelRoot.GetComponentsInChildren<Renderer>(); if (renderers.Length == 0) { Debug.LogWarning("[AutoCollider] 模型没有 Renderer,无法生成碰撞体"); return null; } // 计算所有子网格的合并包围盒 Bounds bounds = renderers[0].bounds; for (int i = 1; i < renderers.Length; i++) { bounds.Encapsulate(renderers[i].bounds); } // 将世界坐标包围盒转换到模型局部坐标下 BoxCollider box = modelRoot.AddComponent<BoxCollider>(); box.center = modelRoot.transform.InverseTransformPoint(bounds.center); Vector3 localSize = new Vector3( bounds.size.x / modelRoot.transform.lossyScale.x, bounds.size.y / modelRoot.transform.lossyScale.y, bounds.size.z / modelRoot.transform.lossyScale.z ); box.size = localSize; return box; } }这个方法看起来直接,但有几个参数容易被忽略:bounds是包围盒的世界坐标尺寸,如果模型根部有缩放,直接赋给box.size会放大碰撞体;所以必须除以 lossyScale。另一个容易被忽略的是InverseTransformPoint转换 center,不然模型有旋转时碰撞体偏移。对碰撞精度要求高的场景(比如楼梯、斜坡),BoxCollider 不够,需要 MeshCollider——但 MeshCollider 的构建性能很差,且不能参与刚体间的高效碰撞(在 Unity 物理引擎里,Movable 刚体之间的 MeshCollider 碰撞开销极大)。我的策略是:默认生成 BoxCollider,需要精确碰撞时再手动切换为 MeshCollider。
4.2 碰撞体参数写入编辑面板
一个完整的功能不能只生成碰撞体,还要允许用户查看和修改它的中心点、尺寸、旋转,这些信息最终要进入保存流程。给编辑面板加一块区域,显示碰撞体的类型、center 和 size。关键点在于:碰撞体的 center 是相对根物体的局部坐标,size 是碰撞体自身坐标系里的尺寸,和模型的世界缩放无关。修改面板输入框时,直接赋值给 Collider 字段即可。对应字段如下:
| 属性 | 数据类型 | 说明 | 示例值 |
|---|---|---|---|
| Center | Vector3 | 碰撞体中心在父物体局部坐标系中的位置 | (0, 1.2, 0) |
| Size | Vector3 | 碰撞体在自身坐标系中的尺寸 | (2, 3, 1) |
| Direction | int (0/1/2) | 胶囊体/圆柱体的主轴方向 | 1 表示 Y 轴 |
| Radius | float | 胶囊体半径 | 0.5 |
| Height | float | 胶囊体总高度 | 2.0 |
| IsTrigger | bool | 是否为触发器 | false |
对 BoxCollider,只暴露 Center 和 Size 就够;对 CapsuleCollider 则要额外暴露方向/半径/高度。面板数据源应该来自数据层而不是直接读取 Collider 组件——用户改完未点击“应用”时,面板值和实际场景值允许暂时不一致,这也是撤销重做功能的地基。
4.3 复合碰撞体预算与性能边界
简单场景一个模型一个碰撞体足够,但复杂模型(建筑、家具)通常需要多个碰撞体拼出精确的物理外形。做法是在数据层设计一个碰撞体数组,运行时自动生成后允许用户复制碰撞体、调整位置、微调尺寸。复合碰撞体的数量直接影响物理模拟性能,每个额外的 BoxCollider 会显著增大 PhysX 的接触计算量。经验阈值是:同屏复合碰撞体总数不超过 64 个,单个物体不超过 8 个。超过这个量级时,优先考虑用凸包逼近的 MeshCollider 替代多个 BoxCollider。这个阈值不是硬性限制,只是性能开始明显下滑的拐点,在低端移动设备上尤其严重。
5. 运行时模型编辑与保存的 5 个常见坑
5.1 模型二次加载后材质丢失
现象:第一次从 AssetBundle 加载模型显示正常;第二次从同一路径加载,模型变成紫色或全白。原因:第一次加载的 bundle 被Unload(true),导致共享材质被卸载,第二次加载引用的材质对象已经失效。解决:卸载时使用Unload(false),只在彻底不再使用时手动处理纹理。改进方法:加载材质后复制一份独立的 Material,让模型实例持有副本:
Renderer[] renderers = instance.GetComponentsInChildren<Renderer>(); foreach (var r in renderers) { r.material = new Material(r.sharedMaterial); // 常见做法:实例化材质副本 }注意这会增加显存占用,大量模型时需权衡。对编辑器工具来说,副本材质是安全的默认选择。
5.2 MeshCollider 的“碰撞不触发”
现象:模型加载后添加了 MeshCollider,但刚体从上方掉落时直接穿模。排查后确认 Collider 确实挂上了,Mesh 也有三角形数据。原因有两个,第一是 MeshCollider 的cooking阶段没有生成凸包数据,非凸网格作为触发体时可以返回碰撞结果,但刚体之间不能施加物理力;第二是网格模型有大量非流形边(open edges),PhysX 直接忽略这些边,碰撞区域有孔洞。解决:对移动物体使用MeshCollider.convex = true(注意凸属性会强制把网格按凸包处理,模型凹面会失真),静态环境物体用MeshCollider加inflateMesh参数,或改用复合 BoxCollider 拼合。判断是不是这个问题的快速方法:把 IsTrigger 勾上,看 OnTrigger 是否触发。如果触发说明网格本身是好的,问题出在非凸碰撞结算。
5.3 旋转数据保存后对不齐
现象:保存时把transform.eulerAngles直接写入 JSON,重载后模型姿态和预设文件不一致,尤其是角度接近 90 度时偏差明显。原因:欧拉角存储本身有精度问题,而且 Unity 内部把欧拉角先转四元数再转回欧拉角时,三个轴的顺序和表示方式可能改变(比如 (-80, 0, 0) 变成 (100, 180, 180))。解决:保存时写入Quaternion的四分量(x/y/z/w),而不是欧拉角;读取时直接transform.rotation = new Quaternion(...)。这会让 JSON 文件可读性降低,但不丢失精度。折中方案是保存时同时存一个欧拉角和四元数,校验时把欧拉角转四元数再和保存的四元数比对。
5.4 UnityWebRequest 加载本地文件缓存问题
现象:本地文件更新后,用 UnityWebRequest 重新加载,拿到的是旧包。原因:UnityWebRequest 对 URL 有缓存,同一个 URL 的请求会复用下载数据,包括本地 StreamingAssets 路径。解决:给 URL 加版本号或时间戳参数,绕过缓存。例如:
string url = bundlePath + "?v=" + DateTime.Now.Ticks; UnityWebRequest request = UnityWebRequestAssetBundle.GetAssetBundle(url);这个问题的隐蔽之处在于:第一次加载永远正常,第二次之后才出错,让人误以为是文件写入失败而不是读取缓存。我在某图像处理 Demo 里踩过这个坑,浪费了半个下午排查 IO 层。
5.5 局部坐标和世界坐标混用导致保存数据错乱
现象:模型挂在一个有坐标偏移的父节点下,保存时记录的是世界坐标 Position,重载时重新设置给子物体,结果模型整体错位。原因:父节点的位置和旋转没有被同步保存,局部坐标和世界坐标被混用了。解决:所有保存数据统一使用localPosition/localRotation/localScale,同时额外保存父节点的完整 Transform 信息。如果模型根节点没有父节点容器,需要创建一个空容器并把它作为保存的锚点:
// 保存锚点结构,代替直接存子物体 Transform public class ModelAnchor { public Vector3 anchorPosition; public Quaternion anchorRotation; public Vector3 anchorScale; public ModelEntityData model; }重载顺序必须是先恢复锚点 Transform,再恢复模型局部位姿,否则任何一点的坐标偏差都会被父节点放大。
6. 用 JSON 做保存器:版本号、四元数存储与一键验证
运行时编辑的最终目的是把结果落盘。对比二进制格式、XML、ScriptableObject(它本身是编辑器资源格式,不适合运行时动态写入),JSON 是运行时可写、可读、跨平台最平衡的方案。要控制两个细节:第一是Quaternion不能直接用 Unity 默认的 JsonUtility 序列化,需要用四个 float 字段手动拼接;第二是要给保存文件写一个 version 字段,后续增加字段时可以做兼容升级。保存器核心代码如下:
[System.Serializable] public class ModelSaveData { public int version = 1; public string sourcePath; public Vector3 pos; public float rotX, rotY, rotZ, rotW; // 四元数分量 public Vector3 scale; // 碰撞体信息 public string colliderType; // "Box" / "Sphere" / "Capsule" / "Mesh" public Vector3 colliderCenter; public Vector3 colliderSize; public float colliderRadius, colliderHeight; public int colliderDirection; public bool isTrigger; } public static class ModelSaveHelper { public static string Serialize(ModelSaveData data) { // JsonUtility 是 Unity 自带序列化器,不依赖第三方库 return JsonUtility.ToJson(data, prettyPrint: true); } public static ModelSaveData Deserialize(string json) { return JsonUtility.FromJson<ModelSaveData>(json); } public static void ApplyToTransform(ModelSaveData data, Transform target) { target.localPosition = data.pos; target.localRotation = new Quaternion(data.rotX, data.rotY, data.rotZ, data.rotW); target.localScale = data.scale; } }保存后一定要做“保存→清场→重载→对比数值”的闭环验证。我的习惯是每次保存完立刻把场景里的模型全部销毁,再用保存文件重载一遍,然后逐个字段对比 Transform 和 Collider 参数,偏差大于 0.001 就算失败。这个验证动作能发现很多采访不到的问题。进阶一点,可以把对比结果自动化:把已生效的值和保存文件里写入的值逐个断言,失败时直接红色报错。最后提醒一句:JSON 文件只能保证数值不丢,不保证数值正确——模型的坐标基准、父节点的锚点、导入时的轴修正逻辑,才是决定数据最终正确性的根。希望这个工具链的思路能帮到你的项目。
本文还有配套的精品资源,点击获取