Unity3D运行时模型导入、变换编辑、JSON保存与碰撞体重建实践
简介面向Unity开发者的运行时模型编辑功能源码工程解决项目在运行模式下无法像编辑器一样直接调整模型位置、旋转、缩放以及碰撞体信息的问题。工程整合了外部模型导入、运行时Transform编辑与保存链路支持对预置模型和运行时导入的fbx、obj等模型进行点选编辑、数值面板精准控制并在重新启动后自动复原模型与碰撞体配置。资源共包含1042个文件以cs源码脚本、dll运行库、asset场景与预制体资源、shader及mat材质、fbx模型和unity场景文件为主整体压缩包29.25MB结构完整可直接参考或嵌入现有项目。目前已有142人学习下载适合需要扩展Unity运行期场景编辑能力的中高级开发者可作为功能插件或二次开发基础帮助快速落地模型导入、编辑、碰撞体设置与持久化保存的完整方案。1. 运行时模型导入这个需求到底要解决什么在某个需要把模型文件从本地磁盘拖进场景、立刻调整位置角度再存回去的项目里运行时模型文件导入、编辑和保存位置旋转缩放以及碰撞体信息几乎是所有交互式编辑器、配置工具和桌面端离线工具的标配需求。很多人第一反应是把资源先放进 Unity 工程里再用编辑器拖拽但发布后的版本根本找不到这些文件也有团队直接上商店插件结果碰撞体信息在导出时丢失白白折腾一整个迭代周期。这篇笔记要讲的正是用纯 C# 在 Unity3D 运行时完成模型文件导入、变换编辑、序列化保存和碰撞体重建的完整做法适合做编辑器工具、内容管理系统或自定义模型工作台的开发者。整个方案不依赖特定商业插件核心代码可以直接搬进你的项目里逐步改造。2. 运行时导入模型文件格式选择与加载管线2.1 为什么运行时导入不能走编辑器拖拽每个在 Unity 里做过资源管理的人都会先踩同一个坑编辑器里用AssetDatabase.LoadAssetAtPath非常顺畅但一打包成独立程序或移动端包这个 API 直接失效。原因在于AssetDatabase只存在于编辑器进程中运行时的包体里根本没有原始资源路径和导入器的概念。你在编辑器里拖进场景的 FBX本质上已经被 Unity 的 AssetImporter 转换成了内部的 mesh、material、动画等对象发布的版本只保留这些转换后的对象不保留导入路径。所以运行时导入模型文件的第一个决策点不是「用哪个 API」而是「让用户拿到什么文件」。常见做法是让用户通过文件对话框选择.bundle/.unity3d资源包也就是我们自己预先在编辑器里打包出来的 AssetBundle另一条路是直接让用户选择.fbx/.obj运行时再用第三方解析库转成 Mesh。两条路都有团队在走但第一条是我验证过的稳妥路线AssetBundle 能原样保存模型网格、材质、动画还能附带碰撞体数据。第二条路适合只读几何的场景一旦涉及 Unity 的碰撞体组件和层级关系第三方解析库通常只能给你一个裸 Mesh碰撞体信息基本全靠手动重建后续维护成本很高。我一般会在工程里同时保留两套入口正式功能用 AssetBundle调试备份用 FBX/OBJ 解析。但本文核心实现以 AssetBundle 作为介绍对象因为它能把「位置、旋转、缩放以及碰撞体信息」这条链路完整走通也最贴合大多数源码工程的需求。2.2 用 UnityWebRequest 加载本地 AssetBundle最小可用实现运行时加载本地 AssetBundle 有两种常见方式AssetBundle.LoadFromMemory和UnityWebRequestAssetBundle.GetAssetBundle。对于本地磁盘文件LoadFromMemory 更快且不依赖协程但大文件加载时会有内存峰值UnityWebRequest 方式更贴近真实项目中的文件加载场景而且能直接在协程里等待加载完成。下面是我在内部工具里一直用的加载模板public IEnumerator ImportBundleFromLocal(string bundlePath) { // 用 file:// 协议加载本地 bundle路径需用 Uri 包装 string uri file:/// bundlePath; UnityWebRequest request UnityWebRequestAssetBundle.GetAssetBundle(uri); yield return request.SendWebRequest(); if (request.result ! UnityWebRequest.Result.Success) { Debug.LogError(Bundle load failed: request.error); yield break; } AssetBundle bundle DownloadHandlerAssetBundle.GetContent(request); string assetName bundle.GetAllAssetNames()[0]; GameObject prefab bundle.LoadAssetGameObject(assetName); // bundle 解除前要保留实例引用不能直接 Dispose GameObject runtimeObj Instantiate(prefab); runtimeObj.name RuntimeModel_ Time.time; yield return runtimeObj; }这里的核心有三点。第一路径必须用Uri包装Windows 和 macOS 下直接拼字符串容易漏掉盘符后的斜杠这是最常见的翻车点。第二GetContent拿到的 AssetBundle 必须等Instantiate完成后再卸载否则模型只留下一个空节点。第三LoadAssetGameObject前可以先GetAllAssetNames看一下主资源路径我通常会默认取第一个可实例化对象但更严谨的做法是在资源名里带前缀[Prefab]做过滤。UnityWebRequest 的result在不同 Unity 版本中判断写法略有差异旧版本用isNetworkError || isHttpError我这里用的是新版本统一后的写法如果你的项目还停留在旧版需要按编译器提示切换。2.3 为什么不建议直接解析 FBX/OBJ每次有人问我「能不能让用户直接选一个 .fbx 文件运行时导入」我都会反问一句碰撞体信息从哪来FBX 文件里通常保存的是网格、骨骼和材质引用Unity 的 Collider 组件不是 FBX 标准里的通用概念运行时解析只能把网格扔给你碰撞体信息完全没有。OBJ 更简单连材质都经常缺。如果你的功能只是展示几何模型可以引入运行时解析库但标题里明确要保存位置、旋转、缩放以及碰撞体信息那就必须在编辑阶段保留 Unity 侧生成的组件数据。AssetBundle 打包时能把模型导入设置里的碰撞体生成选项固化运行时读出来就是完整的 GameObject这是原始模型文件不具备的优势。除了碰撞体还有一个容易被忽略的因素坐标轴和缩放。不同 DCC 工具导出的 FBX 可能是 Y 轴或 Z 轴向上Unity 的 FBX 导入器在编辑器阶段会自动做坐标矫正运行时解析库通常不做这套矫正你拿到的 Mesh 顶点可能是原始坐标系物体放进场景后朝向和缩放全错。AssetBundle 方案完全绕开这个坐标系问题因为引擎已经按导入设置处理过了。这个坑我建议第一次做运行时导入的人直接绕开别为了省一次打包流程浪费整个迭代周期。3. 运行时编辑位置、旋转、缩放从 Transform 到可回滚的变更3.1 把 Transform 的读写包装成统一命令接口运行时编辑和编辑器里的手动拖拽有个本质差异编辑器有 Undo 系统支持撤销运行时没有。你要么自己做一个命令栈要么至少把「读了哪些值、改了哪些值」这件事收敛到一个接口上否则后面做保存时根本不知道用户动过什么。我一般会先把 Transform 的关键数据抽成一个纯数据结构[System.Serializable] public class TransformRecord { public Vector3 position; public Quaternion rotation; public Vector3 scale; public void ApplyTo(Transform target) { target.localPosition position; target.localRotation rotation; target.localScale scale; } public static TransformRecord From(Transform target) { return new TransformRecord { position target.localPosition, rotation target.localRotation, scale target.localScale }; } }这个类的价值不是存三个变量而是把「模型当前的变换状态」变成可以被序列化、比较、撤销的普通数据。ApplyTo里要格外注意position、rotation、scale三个值赋值的顺序会影响结果尤其是在层级结构里建议三个一起赋不要分步调。From方法刻意读localPosition而不是position因为运行时编辑一般针对挂在某个容器下的模型用世界坐标会导致后面保存时出现「换个父节点位置就飞了」的问题。拿到这个记录后编辑操作就变成了简单的一行调用。用户界面上的三个 Slider 或 InputField分别改 record 里的值确认后调用record.ApplyTo(targetTransform)。如果项目需要撤销就维护一个StackTransformRecord每次编辑前 push 一份旧值撤销时 pop 并 apply。这里没有把撤销机制硬编码在数据类里因为很多需求只要保存不一定撤销别为没用的功能增加复杂度。3.2 位置、旋转、缩放的参数边界和数值陷阱位置和缩放都是 Vector3但它们的边界完全不同。位置的 X/Y/Z 单位是 Unity 的世界单位一单位大约对应一米这个语义在保存文件里很重要后续如果要把坐标传给其他系统需要明确约定单位。缩放单位是倍率1 表示原始大小做鼠标拖拽缩放的步长一般为 0.05 或 0.1避免用户拖出太离谱的值。这里有个我踩过很多次的坑缩放值搞成负数模型看起来是镜像了但碰撞体和 Mesh 的烘焙会出问题后面专门在第 5 章展开。旋转是三个数值里最容易出问题的。Unity 的 Inspector 显示的是欧拉角但运行时和序列化里保存的是四元数Quaternion。很多新手会把欧拉角直接存进 JSON恢复时再用Quaternion.Euler转回来这一步本身没错但连续编辑时欧拉角会出现万向锁导致一个轴无论如何转都不动。我的建议是编辑界面上允许用户输入欧拉角但底层保存一律用Quaternion的四元数四个分量或者用localRotation的三个欧拉分量存也行只是要以Quaternion.Euler作为唯一转换入口。绝对不要在保存时同时存四元数又存欧拉角两个来源不一致时加载出来的模型旋转永远是错的。下面这个表是我在做参数设计时常用的边界参考也是后续保存校验的规则参数存储类型有效范围运行时调整步长说明positionVector3无硬性限制但建议 -1000~10000.1 或 0.05超过范围容易引发浮点精度问题rotationQuaternion / Vector3(欧拉)欧拉角 -360~360四元数分量 -1~11° / 0.5°保存用四元数四个分量最稳scaleVector30.01~100禁止负数0.05负缩放的后果参见第 5 章这里还要提一下局部坐标和世界坐标的选择。做运行时编辑面板时用户看到的是「模型在场景里的最终位置」如果模型挂在某个自定义的 Root 下直接改Transform.position会把世界坐标写进 local 字段Unity 内部换算会产生轻微误差。所以我的习惯是所有编辑操作都在localPosition/localRotation/localScale上完成并把「当前父节点是谁」作为一个隐藏字段跟踪。这样做的好处是保存数据与场景结构解耦坏处是如果父节点在运行时被移动模型会跟着动保存后再加载需要先恢复父节点层级。这个取舍在 4.3 节会具体展开。3.3 编辑后要联动更新的数据网格、材质、碰撞体Transform 变了并不等于模型状态全变了至少有三类东西需要手动通知。第一个是网格渲染器的 LOD 和包围盒Unity 大部分情况下会自动重新计算但如果你禁用了渲染后自己管理剔除位置变化后要手动调用renderer.RecalculateBounds()。第二个是材质模型缩放不变时材质不需要动但如果你做了非等比缩放纹理采样和法线可能看起来是拉伸的这不是 bug而是数据本身的语义问题。第三个是碰撞体BoxCollider 和 SphereCollider 的 center、size 基于模型自身坐标如果模型被旋转或缩放碰撞体理论上跟着 Transform 自动变化但 MeshCollider 的情况比较复杂它烘焙的是模型原始顶点的世界坐标运行时修改 Transform 后 Unity 会自动更新前提是初始配置正确。所以编辑操作的正确姿势不是只改 Transform 就完事而是把「通知其他系统」也挂到同一个入口上。下面是一个稍微完整的编辑方法public void ApplyEditing(Transform target, TransformRecord newValues, bool rebuildCollider) { newValues.ApplyTo(target); target.GetComponentRenderer()?.RecalculateBounds(); if (rebuildCollider) { Collider col target.GetComponentCollider(); if (col is BoxCollider box) { box.center newValues.position; // 错误示范center 是相对模型自身坐标不应等于 position } if (col is MeshCollider meshCol) { meshCol.enabled false; meshCol.enabled true; // 触发重新烹饪 } } }上面这段代码我故意留了一个错误box.center newValues.position是错的因为center是相对于模型自身原点的偏移不是世界位置。实际项目中应该把newValues里单独保存一个colliderCenter字段。我在这里展示它是想说明一个边界Transform 和 Collider 参数虽然看起来都是 Vector3但语义完全不同不能互相替代。如果你只是想移动整个模型BoxCollider.center不应该跟着改它应该保持相对模型自身的偏移。只有当你的编辑器支持「单独编辑碰撞体」功能时才需要更新 center 和 size。做这个功能最容易犯的错误就是保存数据里只有一组 position恢复时既赋给 Transform又赋给 Collider最后模型位置对了碰撞体却偏离了。4. 运行时保存把编辑结果写成可再次导入的文件4.1 保存什么变换信息、模型引用、碰撞体参数的序列化范围很多团队第一次做保存只把 Transform 的三个值写了 JSON结果用户下次打开模型又回到初始位置自然认为是功能 bug。实际上运行时保存的最小集比想象中多模型资源包路径、加载到场景后的实例名、局部变换、父节点路径、碰撞体类型和参数还有当前场景时间戳。模型资源包路径决定下次能不能找到相同的 AssetBundle实例名决定场景里多实例时不混淆父节点路径决定恢复后层级关系是否正确。下面是一份我常用的保存字段清单你可以直接作为设计起点字段类型说明bundlePathstringAssetBundle 的绝对路径或资源标识prefabNamestring从 bundle 加载的主资源名parentPathstring实例挂载的父节点路径空串表示场景根localPositionVector3编辑后的局部坐标localRotationQuaternion编辑后的局部旋转存四元数分量localScaleVector3编辑后的局部缩放colliderTypestringnone/box/sphere/meshcolliderCenterVector3碰撞体相对模型自身偏移colliderSizeVector3Box 尺寸或 Sphere 半径的 x 分量meshColliderConvexboolMeshCollider 是否开启凸体lastEditedTimestring记录编辑时间用于日志排查这里特别说一下碰撞体参数为什么一定要分开保存。Transform.localPosition是模型在世界坐标里的位置Collider.center是碰撞体在模型自身坐标里的偏移两者不仅语义不同层级也不同。如果在保存时只存一个 position恢复时全赋给 Transform那碰撞体的偏移就丢了再如果恢复了 Transform 后把碰撞体 center 也设成同一个值那碰撞体可能跑到模型外老远。正确的姿势永远是各存各的恢复时各赋各的。4.2 用 JSON 保存运行时编辑结果字段设计与代码Unity 自带的JsonUtility支持把带[System.Serializable]的类序列化成 JSON但它不支持字典、不支持接口、不支持把 Unity 的 Object 直接序列化。我们的保存内容正好全是值类型完全够用。下面这个类我在多个项目中复用结构上故意把 Transform 和 Collider 分开[System.Serializable] public class ModelSaveData { public string bundlePath; public string prefabName; public string parentPath; public Vector3 localPosition; public Quaternion localRotation; public Vector3 localScale; public string colliderType; // none/box/sphere/mesh public Vector3 colliderCenter; public Vector3 colliderSize; public bool meshColliderConvex; public string SaveToJson() { return JsonUtility.ToJson(this, true); } public static ModelSaveData FromJson(string json) { return JsonUtility.FromJsonModelSaveData(json); } }两点要注意。第一JsonUtility.ToJson(this, true)第二个参数传 true 表示带缩进方便人工排查和版本对比正式发布版本为了省体积可以传 false但我做工具类一般不开。第二Quaternion在 JSON 里会被序列化为{x:0.0,y:0.0,z:0.0,w:1.0}如果直接用文本编辑器改这个文件很容易手滑把 w 改成 0恢复后模型旋转完全错乱。我在导出时会额外写一个只读的欧拉角字段用于人工核对但加载时不会读取它只是给人看的。实际保存时要做的就是把运行时编辑界面上拿到的TransformRecord和碰撞体数据填进ModelSaveData。这里有一个顺序陷阱必须先读取当前 Transform 的局部值再去读碰撞体参数不要在用户还没提交编辑时就开始保存。我一般会把「保存」按钮放在编辑确认事件之后编辑确认时更新TransformRecord保存时直接序列化这个 record数据源唯一避免出现界面显示位置和保存位置不一致的情况。4.3 从保存文件恢复到运行现场加载与反序列化的顺序恢复流程比保存流程容易出错因为要按顺序做四件事加载 bundle → 实例化 → 找父节点 → 设置 Transform 和 Collider。顺序一旦颠倒轻则位置偏移重则碰撞体无法创建。下面是一个完整的恢复函数public IEnumerator RestoreFromSave(ModelSaveData data) { // 1. 加载 bundle AssetBundle bundle null; UnityWebRequest req UnityWebRequestAssetBundle.GetAssetBundle(file:/// data.bundlePath); yield return req.SendWebRequest(); if (req.result UnityWebRequest.Result.Success) { bundle DownloadHandlerAssetBundle.GetContent(req); } else { Debug.LogError(restore failed, bundle can not load); yield break; } // 2. 实例化模型 GameObject prefab bundle.LoadAssetGameObject(data.prefabName); GameObject instance Instantiate(prefab); // 3. 恢复父节点 if (!string.IsNullOrEmpty(data.parentPath)) { Transform parent GameObject.Find(data.parentPath)?.transform; if (parent ! null) instance.transform.SetParent(parent); } // 4. 设置局部变换 instance.transform.localPosition data.localPosition; instance.transform.localRotation data.localRotation; instance.transform.localScale data.localScale; // 5. 重建碰撞体 ApplyColliderData(instance, data); }第五步的ApplyColliderData放在下一章讲。这里先强调顺序必须先SetParent再设置局部变换。如果你的父节点没有找到localPosition 实际被设置到场景根坐标位置就会不对所以在保存时我会把parentPath用正斜杠拼接恢复时用GameObject.Find查找。GameObject.Find对隐藏节点无效如果你的模型是运行时创建并挂在隐藏节点下的需要在保存时记录对象在层级中的完整路径或用一套独立的 ID 系统。这个边界很典型编辑器工具可以人工确认层级但如果是给用户用的工具必须处理隐藏父节点的情况。5. 碰撞体信息重建与运行时导入的 5 个避坑点5.1 MeshCollider 与 BoxCollider 的运行时重建方式运行时重建碰撞体关键不是「AddComponent」这一个操作而是碰撞体参数如何从保存数据映射到组件属性。BoxCollider 只需要把保存的 center 和 size 赋进去SphereCollider 把 size.x 当半径最难的是 MeshCollider因为网格数据本身也需要从模型 Mesh 里取。下面是一个我实际在用的重建函数public void ApplyColliderData(GameObject model, ModelSaveData data) { // 移除旧的碰撞体避免重复加载后叠加 foreach (Collider old in model.GetComponentsInChildrenCollider()) { if (!old.isTrigger) DestroyImmediate(old); } Collider target null; switch (data.colliderType) { case box: BoxCollider box model.AddComponentBoxCollider(); box.center data.colliderCenter; box.size data.colliderSize; target box; break; case sphere: SphereCollider sphere model.AddComponentSphereCollider(); sphere.center data.colliderCenter; sphere.radius data.colliderSize.x; target sphere; break; case mesh: MeshCollider mesh model.AddComponentMeshCollider(); MeshFilter filter model.GetComponentMeshFilter(); if (filter ! null) mesh.sharedMesh filter.sharedMesh; mesh.convex data.meshColliderConvex; mesh.cookingOptions MeshColliderCookingOptions.CookForFasterSimulation | MeshColliderCookingOptions.EnableMeshCleaning; target mesh; break; } if (target ! null) { Physics.SyncTransforms(); // 强制物理系统同步当前变换避免碰撞体位置滞后 } }这里用DestroyImmediate而不是Destroy是有原因的运行时如果在编辑流程里用Destroy销毁会延迟到帧末紧接着执行的AddComponent可能拿到一个临时状态导致碰撞体重复添加。DestroyImmediate虽然在运行时使用有可能产生编辑器警告但在纯工具场景里是可控的。另一个细节是MeshCollider.cookingOptions这个枚举在不同 Unity 版本里名字和默认值有变化如果你是老项目直接设置mesh.cookingOptions可能报枚举不存在稳妥做法是只在需要时给它赋值不要每次都覆盖默认配置。Physics.SyncTransforms()在重建碰撞体后调用可以立刻让物理系统感知到 Transform 的变化避免碰撞体位置滞后。5.2 碰撞体数据在导入-编辑-保存链路里的流失问题很多模型文件导入 Unity 后根本没有任何 Collider。你想在运行时编辑并保存碰撞体信息但源数据里压根没有。为什么会这样因为 FBX/OBJ 本身不带 Unity 碰撞体概念导入器默认不会给模型创建 Collider除非你在模型导入设置里勾选了Generate Colliders。即使勾选了生成的也只是和 Mesh 形状一致的 MeshCollider不会是 Box 或 Sphere。所以如果你想给用户提供的是「简单盒体碰撞」而不是真实网格碰撞就得在运行时手动计算包围盒来生成 BoxCollider。这个流失问题在链路里会出现两次。第一次在导入阶段AssetBundle 打包时如果模型的导入设置没有勾选碰撞体生成包内就是零碰撞体第二次在保存阶段你读出Transform做序列化时没有检查GetComponentCollider()是否存在直接写了一个 null恢复时自然什么都没有。我的习惯是导入成功后立刻执行一次「碰撞体补齐」操作对没有 Collider 的模型自动添加 BoxCollider并把包围盒的中心和尺寸写进保存数据。这样用户的模型一进来就有可编辑的碰撞体而不是等保存时才发现数据缺失。5.3 避坑记录5 个必踩的坑下面是做这个源码工程时真实踩过、也看过其他团队踩过的五个高频坑每一条都按「现象 → 原因 → 解决」描述。坑 1加载后模型是空的只有节点没有 Mesh。现象AssetBundle 加载成功实例化出来的是一个空 GameObject子物体都在但网格不显示。原因LoadAssetGameObject加载的是 prefab 的根节点但模型实际网格在子物体上根节点本身没有MeshRenderer。解决加载后不要急着用根节点当模型先遍历GetComponentsInChildrenMeshRenderer()确认网格在哪个节点保存数据时记录这个子节点的名称或路径恢复时对子节点做变换操作。这里最常见的是直接Debug.Log(model.name)发现名字没问题但忘了看子物体。坑 2重新加载后碰撞体跑到模型外面。现象位置恢复后模型看起来正常但物理检测点在模型旁边半米。原因保存时把Collider.center设成了模型的世界坐标恢复时又赋给了 local 坐标。解决严格区分transform.position和collider.center保存时从BoxCollider.center读恢复时只赋给 BoxCollider不碰 Transform。如果发现两者数值一致十有八九是赋值代码把两个变量用混了。坑 3MeshCollider 报错 “Failed to cook mesh” 或 “Convex Mesh” 创建失败。现象每次加载模型后控制台刷红色报错碰撞体不生效。原因MeshCollider 需要网格在 CPU 侧可读但 AssetBundle 打包时没有勾选Read/Write Enabled网格数据只存在于 GPU 侧物理引擎无法烹饪。解决在模型导入设置Model Read/Write Enabled打勾重新打包 AssetBundle。这个选项会增大包体但运行时碰撞体重建必须依赖 CPU 侧顶点数据该省的地方可以省这里省不掉。坑 4保存的旋转值恢复后完全不对而且每次保存角度都不一样。现象同一个模型编辑旋转 30 度导出 JSON 后打开看欧拉角有时是 30有时是 390还有时是 -330。原因Unity 的eulerAngles输出会进行范围归一化而四元数转欧拉角存在多解直接保存eulerAngles再做Quaternion.Euler恢复遇到接近 360 度边界会产生不可控误差。解决保存时一律用localRotation四元数的 x/y/z/w 字段不要再从欧拉角转换一次。编辑界面可以继续用欧拉角输入但底层存储和恢复都走四元数这样任何角度都能精确复原。坑 5把保存文件复制到另一台机器模型加载失败。现象在开发机上保存的 JSON拷到别的电脑或另一份打包程序里导入bundle 加载不出来。原因保存的是本地绝对路径比如D:\Work\xxx.unity3d换机器后路径不存在。解决保存时把 bundlePath 做相对化处理只记录相对于应用Application.streamingAssetsPath或某个固定资源目录的文件名。运行时拼接完整路径时再根据当前平台区分file://协议。这是给人用的工具必须处理的问题自己本地调不出来别人一用就翻车。6. 进阶把运行时编辑器做成一键导入保存面板6.1 用 Runtime Panel 而不是 EditorWindow 的原因前几章的代码已经覆盖导入、编辑、保存、碰撞体重建但要把它们串成一个工具还需要界面。我见过不少团队把这类功能做成 EditorWindow开发时很方便但发布后的程序里看不到窗口等于白做。既然标题强调「运行时」正确的选择是搭建 Runtime Panel——在游戏场景里的 Canvas 上放几个按钮用同一个 C# 类挂到 GameObject 上逻辑和编辑器窗口共用一套。这样开发时可以挂到场景里跟 Play Mode 同步调试打包后用户也能直接操作。在面板上我习惯用一个缓存字段保存当前选中模型避免每次点击按钮都重新查找。按钮事件只调用统一命令入口命令内部判断当前状态是编辑、保存还是加载这样 UI 组件本身不直接依赖 AssetBundle 逻辑。保存文件时我用Application.persistentDataPath作为默认目录不写死某个固定盘符这样真机和台式机行为一致。6.2 一键导入-编辑-保存的验证脚本为了让整个功能可复现我一般会写一个冒烟测试脚本挂在一个空节点上依次执行导入、改位置、保存、再导入每步都输出日志。下面是一个精简版public GameObject LoadBundleSync(string bundlePath) { // 同步加载仅用于流程验证正式功能优先用协程避免卡顿 AssetBundle bundle AssetBundle.LoadFromFile(bundlePath); if (bundle null) return null; GameObject prefab bundle.LoadAssetGameObject(bundle.GetAllAssetNames()[0]); GameObject instance Instantiate(prefab); // false 表示不释放实例化后加载的对象只释放 bundle 镜像 bundle.Unload(false); return instance; } public void SmokeTest() { Debug.Log(Step1: import bundle); GameObject model LoadBundleSync(C:/Models/runtime_model.unity3d); if (model null) return; Debug.Log(Step2: edit transform); TransformRecord record TransformRecord.From(model.transform); record.position new Vector3(2, 1, 0); record.rotation Quaternion.Euler(0, 45, 0); record.ApplyTo(model.transform); Debug.Log(Step3: save json); ModelSaveData data BuildSaveData(model); string json data.SaveToJson(); Debug.Log(Step4: restore from json); StartCoroutine(RestoreFromSave(ModelSaveData.FromJson(json))); Debug.Log(Smoke test done); }验证的要点是打印data.localPosition和恢复后的model.transform.localPosition是否一致同时检查碰撞体中心是否同步。如果两值不一致优先看是不是父节点路径没恢复对如果一致但物理效果不对再看 collision center。这个脚本值得留在工程里每次改完保存逻辑都跑一遍能挡住大多数回归问题。LoadBundleSync里的bundle.Unload(false)又是个容易记错的参数true 会连同已加载出来的资源一起卸载实例对象会丢失false 只卸载 bundle 镜像保留资源对象这里必须用 false。最后我想说一个习惯问题。最初我自己做这套功能时为了省事直接保存欧拉角和世界坐标结果用户改了几次角度后模型乱飞查了整整一个下午才发现是四元数转欧拉角的精度损耗。后来痛定思痛把所有的运行时编辑数据统一收敛到TransformRecord并用四元数保存这才没有继续踩坑。如果你也在做类似的运行时模型编辑工具建议从一开始把与 Transform 直接打交道的代码收敛成一个类来处理不要到处散落赋值语句。希望帮到你。本文还有配套的精品资源点击获取