Unity运行时模型编辑:从AssetBundle/OBJ导入到JSON保存的实现
简介面向Unity开发者的运行时模型处理源码工程解决程序运行过程中动态导入外部模型文件并对其位置、旋转、缩放和碰撞体信息进行编辑、保存的问题。工程整合了第三方模型导入工具与场景对象操作控件同时提供独立的属性输入面板支持通过数值精确调整模型和碰撞体的各项参数适合有运行时资源管理需求的中高级开发人员。压缩包为7z格式共1042个文件以C#脚本、插件动态库、着色器、三维模型、场景配置、材质贴图及说明文档等类型为主整体约29.25MB脚本与动态库覆盖核心导入、编辑和保存逻辑模型、材质及着色器可供直接参考或替换已有142人学习/下载。源码工程完整涵盖导入、编辑、保存与重启加载流程外部模型文件复制到程序目录后可载入场景点选对象即可通过控件拖拽调整位置、旋转和缩放也能在属性面板中微调数值碰撞体信息同样支持编辑和持久化程序下次启动时会根据保存的数据恢复模型和属性。项目目录结构清晰便于按模块复用适合直接集成到实际Unity项目中二次开发。1. 运行时模型导入与编辑把“编辑器依赖”从工具链里拆掉一个三维场景编辑工具如果在开发期靠 Unity 编辑器手动拖模型、调参数那它只能在开发机上跑。可一旦产品要交付给运营人员让他们在客户端里直接换模型、改摆放、调大小、存配置就必须把“导入 — 编辑 — 保存”整条链路搬进运行时。这是我做模拟项目X一个轻量级场景配置工具时最痛的需求用户不想碰编辑器工程他们只想要一个面板能在程序里加载外部模型文件拖动调整位置旋转缩放再一键把调整结果连同碰撞体信息存下来。这套能力拆出来就是本文要解决的四个核心点运行时文件导入、Transform 编辑、碰撞体配置、数据序列化。适合三类读者做场景编辑工具的人、做模型预览与批处理的人、以及想在 Unity 工程里集成动态资源加载能力的团队。下面直接进入实现路径。2. 运行时模型文件导入AssetBundle 与 OBJ 解析两条主路2.1 为什么编辑器导入在运行时场景下失灵Unity 编辑器里的“导入”本质上是一套资源管线FBX 经过模型导入器处理生成 Unity 内置的 Mesh 资源、Avatar 骨骼信息、材质映射关系再打包进工程。这套流程在编辑器里很顺手但它依赖编辑器上下文进程跑起来之后没有导入窗口、没有 InspectorAssetDatabase 也无法在构建后的产物里正常使用。运行时能稳定读取的模型数据来源常见做法是两条一是把模型预先放进 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 namebundlePathbundle 文件路径比如 Application.streamingAssetsPath /models.bundle/param /// param nameassetNamebundle 内的资源名称比如 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.LoadAssetAsyncGameObject(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顶点、vtUV、vn法线、f面索引四类记录。写解析器的内核是逐行读取按空格分割 token 并填充顶点数组。真正的难点在别处OBJ 坐标系是右手系Y 轴向上对应 Z 轴向外Unity 是左手系Y 向上、Z 向前/Z 指向摄像机模型导入后往往需要绕 X 轴旋转 -90 度否则模型是“躺”在地上的。第二个坑是法线很多 OBJ 文件本身不带 vn 记录渲染会出现黑色模型需要按三角形面法线加权重算。第三个坑是 f 记录里的索引可能是负数相对索引也要处理。解析结束后把 Vector3 数组直接 assign 给 Mesh.vertices再手动设置 triangle、uv、normals调用mesh.RecalculateNormals()。完整解析器要两百行左右但核心骨架如下ListVector3 verts new ListVector3(); ListVector2 uvs new ListVector2(); ListVector3 normals new ListVector3(); Listint tris new Listint(); 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按行读取再用预分配数组或ListVector3(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.GetComponentsInChildrenRenderer(); 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.AddComponentBoxCollider(); 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 字段即可。对应字段如下属性数据类型说明示例值CenterVector3碰撞体中心在父物体局部坐标系中的位置(0, 1.2, 0)SizeVector3碰撞体在自身坐标系中的尺寸(2, 3, 1)Directionint (0/1/2)胶囊体/圆柱体的主轴方向1 表示 Y 轴Radiusfloat胶囊体半径0.5Heightfloat胶囊体总高度2.0IsTriggerbool是否为触发器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.GetComponentsInChildrenRenderer(); foreach (var r in renderers) { r.material new Material(r.sharedMaterial); // 常见做法实例化材质副本 }注意这会增加显存占用大量模型时需权衡。对编辑器工具来说副本材质是安全的默认选择。5.2 MeshCollider 的“碰撞不触发”现象模型加载后添加了 MeshCollider但刚体从上方掉落时直接穿模。排查后确认 Collider 确实挂上了Mesh 也有三角形数据。原因有两个第一是 MeshCollider 的cooking阶段没有生成凸包数据非凸网格作为触发体时可以返回碰撞结果但刚体之间不能施加物理力第二是网格模型有大量非流形边open edgesPhysX 直接忽略这些边碰撞区域有孔洞。解决对移动物体使用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.FromJsonModelSaveData(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 文件只能保证数值不丢不保证数值正确——模型的坐标基准、父节点的锚点、导入时的轴修正逻辑才是决定数据最终正确性的根。希望这个工具链的思路能帮到你的项目。本文还有配套的精品资源点击获取