
简介一套面向Unity开发者的运行时模型导入、编辑与保存源码工程解决运行模式下无法像编辑器那样直接调整模型位置、旋转、缩放及碰撞体信息的问题。工程支持导入外部模型文件将其复制到程序目录并加载进场景自动添加碰撞体作为可编辑对象既可直接点选模型拖动编辑也可通过属性输入面板做数值精确控制编辑结果自动保存下次启动时按保存信息自动恢复。压缩包共1042个文件以C#脚本、DLL库、FBX模型、材质、Shader、Unity场景等类型为主包含137个C#源码、162个DLL依赖、8个FBX示例模型等整体约29.25MB目录结构清晰。目前已有140人学习适合有Unity基础、需要实现运行时动态模型编辑或二次开发的读者。源码整合了TriLib模型加载与RuntimeTransformGizmos操作组件并带有碰撞体编辑及完整保存恢复逻辑可帮助理解运行时模型管理流程并快速迁移到实际项目。1. 运行时模型文件导入与编辑保存让打包后的程序也能“摆模型、存位置”很多时候我们在Unity编辑器里摆弄场景对象点一下、拖一下、改个数值特别顺手——Scene窗口的移动/旋转/缩放工具几乎是最省事的交互。问题是这套交互在打包后的Runtime里完全不可用一旦发布成exe或apk用户就只剩干瞪眼的份。这个工程解决的就是这个事在运行时导入外部模型文件fbx/obj/gltf/glb都行把模型变成可编辑对象用RuntimeTransformGizmos提供拖拽手柄再做一套属性输入面板精准改数和编辑碰撞体最后把位置、旋转、缩放和碰撞体参数一起存到本地程序下次启动自动恢复。适合做设备配置工具、装配演示、方案预览这类需要现场换模型、摆位置的场景尤其是给非技术人员用的交互式程序。2. 运行时模型文件导入TriLib加载、文件复制与碰撞体附加流程2.1 TriLib加载管线选型理由、初始化配置与两个native库的作用运行时加载外部模型绕不开一个问题Unity自带的Resources.Load和AssetBundle都要求模型在打包前就进入工程而用户现场选一个fbx丢进来的需求本质上只能走运行时解析原始模型文件的路线。常见的方案有Assimp原生库封装、UnityGLTF、TriLib。这个工程选择TriLib一个重要原因是它同时覆盖了fbx、obj、gltf、glb还内嵌了Draco压缩解压支持能应对格式混乱的外部模型。工程文件里有libdraco.a和libdracodec_unity.a这两个就是Draco格式解压的native层静态库。Draco是glTF/glb模型里常见的顶点压缩方案压缩率能到90%以上但解压逻辑不是纯C#能搞定的必须把这些native库打包进对应平台的Plugins目录。很多项目在编辑器下加载glb一切正常一打包就报DllNotFoundException十有八九就是这两个库没被正确保留。后面避坑章节我会专门展开。初始化上的关键参数这么设public void LoadModelFromFile(string path) { AssetLoaderOptions options AssetLoaderOptions.CreateInstance(); options.AutoLoadModelFromMemory false; // 从文件路径加载 options.ImportTextures true; // 自动导入贴图 options.LoadMaterialsProgressively true; // 大模型渐进加载材质 options.GenerateColliders false; // 碰撞体我们自己按需加 options.MarkAssetsLoadable true; // 加载后允许二次使用该资产 AssetLoader.LoadModelFromFile(path, options, OnModelLoaded, OnModelProgress, OnModelError); }这里解释一下几个选项。AutoLoadModelFromMemory设为false是明确走文件IO路线如果以后要支持从服务器或AssetBundle读字节数组改成true然后调用LoadModelFromMemory即可。GenerateColliders必须关掉TriLib默认生成的碰撞体是按mesh原始顶点来的经常出现几十个collider叠加在根物体上的情况既低效又没法序列化保存所以关闭后在后面自己统一附加。LoadMaterialsProgressively对大体量场景值很有用但要注意材质加载是异步完成的如果模型加载完成后立刻操作材质有可能拿到的是半成品。回调函数里拿到的是GameObject根节点但不是所有模型都适合直接拖进场景。比如从Mixamo下载的带骨骼角色模型加载回来会有AvatarMapper之类的映射信息工程里那个MixamoAndBipedByNameHumanoidAvatarMapper.asset就是配合TriLib处理这类角色绑定的配置。处理模型层级前先把根节点放到一个空容器下便于后面的编辑状态管理private void OnModelLoaded(GameObject loadedModel) { loadedModel.transform.SetParent(editableContainer.transform, false); // 重置姿态避免模型自带缩放/旋转把后续编辑搞乱 loadedModel.transform.localPosition Vector3.zero; loadedModel.transform.localRotation Quaternion.identity; loadedModel.transform.localScale Vector3.one; AttachCollider(loadedModel); RegisterEditableObject(loadedModel); }OnModelLoaded里做三件事挂到统一容器、重置变换、附加碰撞体。第三步是关键细节放到2.3节。2.2 文件复制策略为什么必须先拷贝进程序目录再加载一个非常现实的坑是用户第一次从D盘选了个模型加载进场景编辑完了保存。第二次程序启动时D盘那个路径还在但用户把模型文件删了或者移动了所有保存的数据就跟着失效。所以这个工程的做法是模型选完之后先复制到程序自己管理的目录后续所有加载、保存、恢复都只跟程序目录打交道。public string CopyModelToPersistent(string sourcePath) { string destDir Path.Combine(Application.persistentDataPath, ImportedModels); if (!Directory.Exists(destDir)) { Directory.CreateDirectory(destDir); } string fileName Path.GetFileName(sourcePath); string destPath Path.Combine(destDir, fileName); // 如果目标已存在说明之前导入过同名文件直接复用 if (!File.Exists(destPath)) { File.Copy(sourcePath, destPath); } return destPath; }Application.persistentDataPath在不同平台上有不同的物理位置Windows下在C盘AppData目录Android/iOS下是沙盒目录但共同点是程序有完全的读写权限。StreamingAssets是只读的不适合放运行时产生的文件自己手动建的Assets目录在打包后根本不存在。所以持久化目录是唯一靠谱的选择。还有一个细节是考虑文件名冲突如果用户导入了两个同名不同内容的模型这里会直接复用旧文件严谨的做法是在文件名后面追加时间戳或GUID我一般会给导入操作加一个递增编号比如model_001.fbx、model_002.fbx避免同名覆盖导致旧数据错乱。2.3 碰撞体附加从Renderers计算Bounds并生成可编辑的BoxCollider给导入模型加碰撞体不能图省事用MeshCollider直接怼。MeshCollider的碰撞体数据是按mesh的顶点三角形来的一方面性能开销大另一方面你很难在UI面板上编辑碰撞体信息——用户没法对着一堆三角形改数据。所以这个工程的做法是遍历所有子物体的Renderer计算一个整体包围盒然后给根物体挂BoxCollider把center和size设置成包围盒的相对值public static BoxCollider AttachCollider(GameObject root) { Renderer[] renderers root.GetComponentsInChildrenRenderer(); if (renderers.Length 0) return null; // 用第一个renderer的bounds初始化再用其余bounds扩展 Bounds combinedBounds renderers[0].bounds; for (int i 1; i renderers.Length; i) { combinedBounds.Encapsulate(renderers[i].bounds); } BoxCollider col root.GetComponentBoxCollider(); if (col null) { col root.AddComponentBoxCollider(); } // 转换成物体本地坐标下的参数 col.center root.transform.InverseTransformPoint(combinedBounds.center); col.size root.transform.InverseTransformVector(combinedBounds.size); return col; }这段代码的核心是把世界坐标系的Bounds转换到物体本地坐标系。InverseTransformPoint处理的是中心点InverseTransformVector处理的是尺寸方向。如果不做这步转换当模型带旋转或缩放时碰撞体参数会存得莫名其妙。BoxCollider的center和size就是两个Vector3后续存JSON、恢复、在UI面板上让用户手动微调都非常直接。如果模型有多个独立部件需要各自碰撞那就要对每个子物体单独执行这套逻辑保存时按节点路径分别记录。3. 运行时编辑交互双通道Gizmos拖拽手柄与属性输入面板3.1 RuntimeTransformGizmos接入手柄初始化、模式切换与缩放系数运行时编辑交互最核心的是让用户像在Scene窗口里一样拖拽操作。RuntimeTransformGizmos插件把Scene窗口那套手柄搬到了Runtime支持移动、旋转、缩放三种模式还带Pivot/Center切换。接入方式很直接using RuntimeGizmos; RuntimeTransformGizmo gizmo editableObj.AddComponentRuntimeTransformGizmo(); gizmo.transformTarget editableObj.transform; gizmo.type TransformType.Move; // 默认移动模式 gizmo.scaleFactor 1.5f; // 手柄大小系数 gizmo.transformed OnGizmoTransformed; // 拖拽过程中回调scaleFactor这个参数很玄学。手柄在屏幕上显示的大小是固定的当物体距离相机很远时手柄看起来会特别小点不到很近时又大到盖住物体。1.5f是适配常规视角的经验值如果你的场景相机距离变化很大建议在运行时根据相机与物体的距离动态更新scaleFactor。切换操作模式时直接改gizmo.type即可Move对应移动手柄Rotate对应旋转球Scale对应轴向缩放块。注意这个插件依赖EventSystem场景里必须有一个可用的EventSystem否则鼠标点击事件传不进去。还要提一个边界手柄的坐标空间默认是物体的本地坐标缩放一个旋转过的物体会出现沿着轴拉扯但方向不对的现象。处理方法是在切换scale模式时把手柄的coordinateSpace改为World或者强制将物体的rotation暂时归零再缩放这是很多工程里常见的血泪调整。3.2 属性输入面板用InputField精准控制并解决双向同步Gizmos适合做粗调比如把模型大致摆在某个位置上。但用户需要把位置精确设在(100.5, 0.0, 88.2)这种具体数值时拖拽手柄是做不到的必须靠UI输入面板。工程里新增的UI面板就是针对这个需求做的。面板上需要显示Position、Rotation、Scale各三个输入框外加碰撞体的Center和Size六个输入框。这里最麻烦的是双向同步用户拖拽Gizmos时UI上的数值要实时刷新用户在UI里输入数值时模型的位置也要跟着变同时Gizmos的手柄不能反过来覆盖输入值。public void OnPositionXInput(string value) { if (float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out float x)) { Vector3 pos selectedObject.transform.position; pos.x x; selectedObject.transform.position pos; } }OnPositionXInput是InputField的onValueChanged回调每敲一个字符都会触发。这里有个反例很多人直接把InputField绑定到一个float字段不做解析校验结果用户输入1.2.3或者空字符串时直接抛异常。TryParse加InvariantCulture是必须的因为有些系统下逗号是小数点不指定CultureInfo会让数值解析错乱。双向同步的要点是防止回调回环。当Gizmos拖拽更新UI时会触发InputField的onValueChangedonValueChanged又去改transformtransform又触发Gizmos的回调……兜圈子的结果就是数值跳来跳去。我一般用一个syncLock标志位来控制private bool syncLock false; private void OnGizmoTransformed() { if (syncLock) return; syncLock true; positionInputX.text selectedObject.transform.position.x.ToString(F2); rotationInputX.text selectedObject.transform.eulerAngles.x.ToString(F2); scaleInputX.text selectedObject.transform.localScale.x.ToString(F2); syncLock false; }syncLock在更新UI前置为trueInputField的onValueChanged触发时看到syncLock为true就直接返回不去改transform。UI输入方向则不受影响——用户在框里输入时回调里直接改transform而transform的变化不会反向触发InputField的text赋值所以天然安全。旋转这里特别提醒transform.eulerAngles是欧拉角和内部Quaternion的换算在特定角度下会出现翻转比如从359度转到0度中间会闪一个很大的数值跳动。要彻底规避可以改成始终显示一个归一化后的角度或者在输入旋转值时同时设置Quaternion而不是欧拉角。3.3 选中逻辑射线命中、层级归属与UI事件穿透可编辑对象支持后第一件事是解决用户点击到底选中了谁。TriLib导入的模型是一个多层级的GameObject树根节点下面有子mesh、骨架节点、空节点。如果射线命中了某个子物体直接把这个子物体作为编辑对象Gizmos会跑到子物体的位置保存时也乱七八糟。一个可靠的方案是命中后用transform.root向上找根节点但这个方法有个前提所有可编辑对象都在一个统一的容器下且根节点上挂了一个可识别的标记组件。private void Update() { if (Input.GetMouseButtonDown(0)) { // 点在UI上时不进行模型选中 if (EventSystem.current ! null EventSystem.current.IsPointerOverGameObject()) return; Ray ray Camera.main.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit, 200f)) { Transform root hit.collider.transform.root; if (root.GetComponentEditableObjectMarker() ! null) { SetSelectedObject(root.gameObject); } } } }Raycast命中依赖Collider这也是2.3节里必须给导入模型附加碰撞体后才能点选编辑的原因。EditableObjectMarker是一个空标记组件挂在导入和预置物体的根节点上用来区分哪些对象可以被编辑哪些只是场景装饰物。IsPointerOverGameObject用来过滤UI点击事件否则你在编辑面板上点击输入框时射线也会把下面的模型选中Gizmos手柄突然跳过来操作面板瞬间失焦体验直接崩掉。4. 运行时编辑保存与启动恢复序列化结构、防抖写盘与加载顺序4.1 数据结构设计模型相对路径、Transform与碰撞体信息的映射保存的本质是把运行时状态变成可重新加载的数据。对于这个工程一台机器上可能有多个导入并编辑过的模型每条数据要能唯一对应一个文件和一个场景对象。[Serializable] public class EditableObjectData { public int schemaVersion 1; // 数据结构版本 public string fileName; // 模型文件名相对ImportedModels目录 public Vector3 position; public Vector3 eulerRotation; // 用欧拉角UI面板直接显示 public Vector3 localScale; public string colliderType; // Box / Mesh / None public Vector3 colliderCenter; public Vector3 colliderSize; } [Serializable] public class EditableObjectList { public ListEditableObjectData items new ListEditableObjectData(); }保存用Unity自带的JsonUtility把EditableObjectList序列化成JSON写文件。注意JsonUtility不支持Dictionary所以用List。为什么不用绝对路径而用fileName因为Application.persistentDataPath在不同平台、不同机器上有不同的物理路径绝对路径换一台电脑就失效了程序目录下的文件名是稳定的恢复时把persistentDataPath和ImportedModels/文件名拼起来就行。position、eulerRotation、localScale直接对应Transform的三个属性。碰撞体用一个字符串类型加两个Vector3参数表达这样以后如果需要添加SphereCollider或CapsuleCollider在类型字符串上扩展即可不需要改动整个数据结构。4.2 保存时机与磁盘写入自动保存防抖和手动保存兜底保存时机是个细节问题。如果每次InputField的onValueChanged都触发写磁盘用户连续敲一长串数字时硬盘会被频繁写入低配机器上能明显感到卡顿。工程采用的办法是自动保存防抖编辑操作引起的任何变更都标记为dirty然后延迟一小段时间统一保存。private Coroutine autoSaveCoroutine; public void MarkDirty() { if (autoSaveCoroutine ! null) { StopCoroutine(autoSaveCoroutine); } autoSaveCoroutine StartCoroutine(SaveDelayed(0.8f)); } private IEnumerator SaveDelayed(float delay) { yield return new WaitForSeconds(delay); SaveAllToDisk(); autoSaveCoroutine null; }0.8秒的延迟意味着用户在输入框里连续输入时只有最后一次输入结束后0.8秒才会落盘。拖拽Gizmos过程中每次移动回调都标记dirty但真正保存是在拖拽停下之后。SaveAllToDisk里做的事就是遍历所有可编辑对象收集它们的transform和collider数据填充EditableObjectData列表然后JsonUtility.ToJson写入文件。public void SaveAllToDisk() { EditableObjectList list new EditableObjectList(); foreach (EditableObjectMarker marker in editableRoots) { EditableObjectData data BuildDataFromObject(marker.gameObject); list.items.Add(data); } string json JsonUtility.ToJson(list, true); string path Path.Combine(Application.persistentDataPath, edit_data.json); File.WriteAllText(path, json); }写盘用File.WriteAllText注意编码默认是UTF-8JsonUtility生成的JSON里中文路径或文件名会按\uXXXX转义读回时JsonUtility会自动还原不需要额外处理。但有一个隐患程序在保存过程中被强杀比如断电、切后台被杀写一半的JSON文件就损坏了。严谨的写法是先生成临时文件写完再改名覆盖或者保留一份edit_data.json.bak作为回滚。这个工程体量不大我在实际项目里通常会加一个备份文件恢复时优先读主文件解析失败就自动fallback到备份。4.3 启动恢复加载JSON后协同加载模型OnLoaded回调赋值启动恢复要处理两个来源打包前预置在场景里的可编辑对象和运行时导入后保存在JSON里的对象。预置对象已经在场景中了序列化场景本身就会保留它们的状态所以恢复逻辑只需要针对运行时导入的模型。private IEnumerator RestoreSavedObjects() { string jsonPath Path.Combine(Application.persistentDataPath, edit_data.json); if (!File.Exists(jsonPath)) yield break; string json File.ReadAllText(jsonPath); EditableObjectList list JsonUtility.FromJsonEditableObjectList(json); if (list null || list.items.Count 0) yield break; foreach (EditableObjectData data in list.items) { string fullPath Path.Combine(Application.persistentDataPath, ImportedModels, data.fileName); if (!File.Exists(fullPath)) continue; bool finished false; AssetLoader.LoadModelFromFile(fullPath, defaultOptions, (loadedModel) { loadedModel.transform.SetParent(editableContainer.transform, false); loadedModel.transform.position data.position; loadedModel.transform.eulerAngles data.eulerRotation; loadedModel.transform.localScale data.localScale; // 重新附加碰撞体并恢复碰撞体参数 BoxCollider col AttachCollider(loadedModel); col.center data.colliderCenter; col.size data.colliderSize; loadedModel.AddComponentEditableObjectMarker(); finished true; }, null, (error) finished true); // 等待当前模型加载完成再处理下一个 float timeout Time.time 10f; while (!finished Time.time timeout) { yield return null; } } }用协程逐个恢复模型核心是等每个模型加载完成后再进入下一轮避免多个模型同时加载时的资源竞争。注意LoadModelFromFile的回调可能是异步的所以用finished标志位加yield return null来等待。加了一个10秒超时防止模型文件损坏时协程卡死。恢复时如果模型曾经被用户拖到了某个位置这里直接赋值position、eulerAngles、localScale。还要说明的是Nested结构如果之前编辑的是子物体而非根物体数据结构里需要记录子物体的路径恢复时用Transform.Find定位到子物体再赋值这个放在最后一章的进阶技巧里展开。恢复流程的调用时机也值得注意。我建议在场景的启动流程里加一个显式的初始化步骤先创建editableContainer空物体再启动RestoreSavedObjects。如果场景本身有需要加载的配置应该在配置加载完后再恢复模型避免两个初始化流程同时操作同一批GameObject造成卡顿。5. 避坑与排查碰撞体丢失、模型坐标漂移与Draco加载失败5.1 模型加载后缩成一团或悬在半空现象在编辑器里加载某个fbx模型模型出现在场景里但要么变成米粒大小要么大得超出屏幕位置也飘在百米高空。原因模型源文件里保存的单位和Unity不一致。比如3ds Max默认单位是厘米导出fbx后TriLib会按导入设置把单位换算到米如果模型原点本身就在很远的地方换算后位置差值会爆炸。还有一种情况是fbx里自带了一个极大的scale增量值比如100倍TriLib没有做归一化直接作用到transform上了。解决在OnModelLoaded回调里对模型做一次归一化。先判断renderer.bounds的尺寸如果size的任何一个分量小于0.01或者大于100说明单位换算出了问题手工重置localScale到1并把模型的根节点位置对齐到世界原点。更稳妥的做法是加载后立刻计算模型的实际bounds然后做一个归零操作把bounds.center设置到根节点的原点位置。Renderer renderer loadedModel.GetComponentInChildrenRenderer(); if (renderer ! null) { Bounds b renderer.bounds; loadedModel.transform.position Vector3.zero; loadedModel.transform.position - b.center; // 同时把根节点的子物体整体偏移修正 loadedModel.transform.localScale Vector3.one; }这里要注意如果模型有多个子renderer单一renderer的bounds不够准确要先用第2.3节的方法算出combinedBounds再归零。做过一次归零之后后续的保存和恢复都以这个新的transform为准不会再漂移。5.2 点击模型没反应手柄不出现现象模型加载成功、显示正常但点击鼠标时场景里没有出现Gizmos手柄选中逻辑像失效了一样。原因最常见的是Collider没加上。TriLib的GenerateColliders设为false后导入模型上是没有任何碰撞体的射线检测直接落空。第二个常见原因是Collider加在了子物体上而射线命中的子物体根节点上没有EditableObjectMarker被选中逻辑直接跳过了。第三个原因是UI遮挡——鼠标点击位置正好被一个透明的UI面板挡住IsPointerOverGameObject返回true选中逻辑被提前return。解决在AttachCollider后立即用Debug.DrawRay或OnDrawGizmos验证碰撞体是否生效。确认根物体上确实存在Collider并且所有可编辑物体的根上都挂了EditableObjectMarker。UI遮挡问题把IsPointerOverGameObject的判断放进来后基本能解决但要注意Canvas的GraphicRaycaster必须配置正确如果Canvas的RaycastTarget覆盖了整个屏幕那点击事件永远被UI吃掉。5.3 打包后加载glb报DllNotFoundException现象编辑器下运行一切正常Build成exe或apk后点击导入glb文件时直接报DllNotFoundException: draco_unity模型加载失败。原因libdraco.a和libdracodec_unity.a是C编译的native库。Unity打包时Plugins目录里的native库默认只打包进当前目标平台对应的文件夹。如果这两个库放在了Assets/Plugins/Android或者x86_64目录下而实际打包目标是Windows x64库文件不会被打进去。还有就是AssetLoaderOptions里没有启用Draco相关的加载标记导致TriLib进入了解压分支找不到库。解决检查Plugins目录中的库文件结构确保目标平台的子目录下存在对应的.a或.so文件。确认TriLib资源路径里的LoaderOptions中DracoCompression相关选项处于开启状态。最保守的做法是导入模型时优先使用不带Draco压缩的glb或fbx规避native库依赖。5.4 编辑器下保存的数据换平台后什么都读不到现象在编辑器里运行程序导入模型、编辑、保存一切正常打包发布后运行同一套代码场景里空空如也JSON数据好像丢了。原因Application.persistentDataPath在编辑器下指向的是项目的Library目录打包后指向的是用户系统的应用数据目录两者完全不同。还有不少项目会把保存路径写死成Application.dataPath /MyData这种形式在编辑器下dataPath是Assets的上级目录打包后dataPath是程序安装目录下的_Data文件夹而且可能只读。两种情况都会导致编辑器里正常打包后读不到。解决把保存路径统一封装成一个静态方法内部只使用Application.persistentDataPath拼接相对子目录。编辑器下可以用#if UNITY_EDITOR做个分支把数据写到Assets目录下方便调试但发布版本必须走persistentDataPath。同时建议在启动时打印当前数据目录的绝对路径方便排查问题。5.5 拖拽旋转后属性输入框里的数值没跟着变现象用Gizmos拖拽旋转模型模型转到了一个新角度但右侧属性面板里Rotation的三个输入框显示的还是旧数值。原因Gizmos手柄的拖拽回调没有通知UI面板做刷新。RuntimeTransformGizmos插件默认的Transformed回调需要手动绑定如果只做了UI输入-改transform的单向绑定拖拽方向的数据流就断了。另外欧拉角本身存在多解问题——同一个Quaternion可以对应多组欧拉角直接从transform.eulerAngles读出来再显示往往会跳成一个预料之外的角度。解决在创建Gizmos时强制绑定transformed事件事件里把transform的最新值推送到UI的InputField文本。推送时用syncLock锁避免回环。针对欧拉角翻转可以把UI面板上的旋转显示值限制在0~360度范围同时配合输入值时按差值最短路径的策略如果用户输入350度而当前是10度理解为绕轴转340度而不是直接赋值350。实现上用Quaternion.Euler输入值引擎内部自动处理差值不会再出现翻转跳变。6. 进阶从根物体到任意子物体编辑以及JSON的版本兼容6.1 把“选中即编辑”扩展到子物体记录Transform路径用于精确恢复到目前为主保存和恢复都是针对根物体的。但实际业务里用户往往只需要调整某个模型的某个部件——比如一台设备模型上的显示屏角度。射线命中后直接命中子物体点选命中后取得其相对根物体的层级路径也就是用一串节点名组成的路径字符串比如Base/Arm/Display。把这条路径和根物体的手动物理参数一起保存下来。恢复时先加载根模型再用Transform.Find(path)定位到目标子物体然后赋值transform属性。需要注意如果层级里有重名节点Transform.Find只能找到第一个推荐给可编辑子物体添加一个唯一标识组件恢复时遍历查找。6.2 数据版本兼容为JSON加schemaVersion字段升级不丢老数据序列化数据结构会随着需求迭代而变。第一次发布只存了transform第二次加上了collider参数第三次又加了材质覆盖项。如果直接用旧JSON反序列化新数据结构JsonUtility会用默认值补齐缺失字段看起来不会报错但会出现老数据加载后碰撞体消失了或者位置被重置的隐性bug。给EditableObjectData增加schemaVersion字段加载时对版本做分支处理switch (data.schemaVersion) { case 1: // v1只有transformcollider相关字段都是默认值需要重新计算 ApplyTransform(data); RecalculateColliderFromMesh(go); break; case 2: ApplyTransform(data); ApplyCollider(data); // v2结构包含完整的collider信息 break; default: Debug.LogWarning(未知的数据版本); break; }这样每次升级数据结构时只需要加一个新的case分支老用户保存的数据不会因为字段变化全部作废。这个习惯在运行时持久化工具里极其重要。我被这个坑狠狠坑过一次加了碰撞体保存后没有升版本号老用户的JSON反序列化后碰撞体全是(Vector3.zero, Vector3.zero)程序启动后模型全部没有碰撞体点选也失效了用户数据又没法回滚只能远程指导用户去删配置文件。从那以后我每次给项目加运行时编辑功能都强制要求数据结构带上schemaVersion并且把加载→附加碰撞体→编辑→保存→恢复五步闭环先在Editor下完整跑一遍再打包验证一次。希望帮到你。本文还有配套的精品资源点击获取