简介:面向Unity开发者的运行时模型处理源码工程,解决在游戏运行阶段动态导入外部模型文件、实时编辑其位置、旋转、缩放及碰撞体信息并持久化保存的完整需求。工程整合TriLib模型加载插件与RuntimeTransformGizmos操作插件,同时提供数值输入面板,支持对模型及碰撞体属性进行精确控制与自动保存,启动时自动恢复场景状态。支持将外部模型复制到程序目录后加载进场景,并自动添加碰撞体作为可编辑对象,适用于关卡编辑器、模型预览工具、工业仿真等场景。压缩包共1042个文件,以C#脚本、DLL插件、Asset配置、FBX模型、材质与Shader等为主,含完整Unity工程目录,可直接打开参考或二次开发,整体约29.25MB。目前已有140人学习下载,适合需要实现运行时模型导入编辑、编辑器工具扩展或Unity交互方案设计的中高级开发者。
1. 运行时导入一张 OBJ,比包一层 AssetBundle 更接近“编辑器动手改”的感觉
很多人一听到运行时模型加载,第一反应是 AssetBundle 或 Addressable,但实际做 3D 模型预览、批量格式转换、玩家自定义模型上传这类工具时,AssetBundle 的离线打包链路反而碍事。这套 Unity3d + C# 源码工程走的是另一条路:运行时直接读取模型文件,解析 Mesh、材质、Collider 数据,再在场景里编辑位置旋转缩放,最后序列化回工程目录。整个过程不打断 Play Mode,适合做编辑器扩展、战斗内模型替换、模型资源校验这类工具链。
对已经熟悉资源管线的人,这套源码的价值不是“怎么加载”,而是它把导入、编辑、保存、碰撞体四件事串在一起,给出了可落地的数据结构和异常处理方式。下文会按加载路径、变换持久化、Collider 重建、验证排错的顺序拆开讲,代码片段可以直接抄到自己的编辑器工具或运行时管理器中。
2. 模型文件导入:先把 OBJ 解析成 Unity Mesh,再谈后面的编辑
2.1 为什么源码默认走 OBJ,而不是 FBX 或 GLTF
Unity 引擎内置的类型在打包后并不保证能解析外部 FBX,FBX SDK 涉及二进制版本和依赖项,放在运行时会有平台兼容问题。OBJ 是纯文本,顶点、法线、UV、三角面都以v、vn、vt、f开头,格式公开且没有平台约束,所以这套工程的运行时导入入口以.obj为主,同时保留.txt和.bytes的读取路径,便于把模型放在 StreamingAssets 或玩家目录下。
如果项目必须加载 FBX,一般做法是离线阶段先用 Unity 的 AssetImporter 转成 AssetBundle,运行时再UnityWebRequestAssetBundle.GetAssetBundle。但这会引入打包流程,无法做到“拿到文件立刻预览”。源码工程把两种方式都留了口子:OBJ 走运行时解析,FBX 走预打包接口,二者共用同一个ModelData中间结构,这样后续编辑和保存逻辑不需要区分来源。
2.2 解析流程:从 FileStream 到 Mesh 的关键处理
核心入口是一个静态方法ObjFileImporter.Import(string path),它负责读取文件字节、按行解析,并最终返回ModelData。ModelData保存了vertices、uvs、normals、triangles、materials以及模型包围盒,而不是直接返回Mesh,目的是让上层可以缓存原始数据用于重新编辑。
public static ModelData Import(string path) { string[] lines = File.ReadAllLines(path, Encoding.UTF8); 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 lines) { string line = rawLine.Trim(); if (line.StartsWith("v ")) { string[] p = line.Split(' '); verts.Add(new Vector3(float.Parse(p[1]), float.Parse(p[2]), float.Parse(p[3]))); } else if (line.StartsWith("f ")) { // 只解析顶点索引,忽略 v/vt/vn 的组合写法 string[] seg = line.Split(' '); tris.Add(int.Parse(seg[1].Split('/')[0]) - 1); tris.Add(int.Parse(seg[2].Split('/')[0]) - 1); tris.Add(int.Parse(seg[3].Split('/')[0]) - 1); } } Mesh mesh = new Mesh(); mesh.vertices = verts.ToArray(); mesh.triangles = tris.ToArray(); mesh.RecalculateNormals(); mesh.RecalculateBounds(); return new ModelData(mesh, path); }这段代码包含两个在实际项目中容易踩的点。一是 OBJ 的索引从 1 开始,Unity 从 0 开始,所以每个索引都要- 1。二是没有提取vn和vt,直接RecalculateNormals会丢失原文件的光滑组信息,对圆角、斜面这类模型表现差异很大。如果要做严谨导入,应该把f行里的v/vt/vn三个索引分别拆开,并处理索引不相同的情况;源码工程原版为兼容性刻意简化了这块,但留了#if OBJ_USE_EXACT_NORMAL的宏定义开关,打开后才会走完整法线映射。
2.3 加载后的单位与坐标系对齐
OBJ 文件常见的单位是厘米,Unity 默认场景单位是米。直接导入会出现模型在场景里放大 100 倍或缩小的错觉。源码工程在Import方法后接了一个ModelScaleUtility.ApplyUnitRatio(transform, ratio),默认按1f处理,但编辑器界面提供0.01f、0.001f两个选项。
public static void ApplyUnitRatio(Transform root, float ratio) { if (Mathf.Approximately(ratio, 1f)) return; root.localScale = new Vector3( root.localScale.x * ratio, root.localScale.y * ratio, root.localScale.z * ratio ); }这里不是修改 Mesh 的顶点数据,而是改 Transform 的 localScale,好处是保存时仍保留原始 OBJ 的数值精度,坏处是后续算 Collider 尺寸时容易忘记 scale 的存在。建议在ModelData里额外存一个UnitScale字段,序列化时和 Transform 一起写入,避免加载两次模型得到不同的显示比例。
提示:如果模型导入后表面出现撕裂或黑色闪面,优先检查法线是否已经 Recalculate,再看
f行的索引格式是否包含//,例如f 1//2 3//4 5//6,用Split('/')[0]仍然有效,但不能直接按空格拆出第二段作为顶点坐标。
3. 编辑位置、旋转、缩放,并把变换数据序列化到本地
3.1 运行时不直接用 Transform,而要包一层可记录的编辑命令
模型导入后拖进场景,变成一个带ModelRoot组件的 GameObject。这个组件保存了模型文件路径、显示名称、所属分组。真实的编辑操作仍然改 Transform,但每次修改前要记录旧值,这样才能支持 Undo/Redo,否则在 Play Mode 里误拖动就无从恢复。
源码里定义了一个TransformRecord结构体,包含Position、Rotation、Scale三项,每次拖拽开始时用TransformRecorder.BeginRecord捕获当前值,鼠标抬起时调用CommitRecord写入 Undo 栈。
private void OnMouseDrag() { Plane plane = new Plane(Camera.main.transform.forward, target.position); Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); if (plane.Raycast(ray, out float enter)) { record.endPosition = ray.GetPoint(enter); target.position = record.endPosition; } } private void OnMouseUp() { if (record.HasChanged()) { UndoStack.Push(record); } }这段代码把屏幕坐标转成射线,再和经过目标点的平面求交,得到拖拽后的世界坐标。比直接transform.position += mouseDelta稳定,因为不受相机视角影响。Plane 的法线用的是Camera.main.transform.forward,如果相机是正交投影,这个平面会和屏幕平行,拖拽手感更接近编辑器里的移动平面;透视相机下位置会略微偏,建议改成Camera.main.transform.forward与场景地面方向的插值。
3.2 序列化方案对比:JsonUtility、Newtonsoft、二进制,如何选
源码工程的保存模块支持三种导出格式,最终版默认用 JsonUtility,原因是它不依赖第三方库,且能直接序列化Vector3、Quaternion等 Unity 原生类型。缺点是字段名固定,不能加注释,不支持多态,这一点在保存 Collider 类型时会暴露。对比见下表。
| 方案 | Unity 原生类型支持 | 多态支持 | 可读性 | 版本兼容 | 推荐场景 |
|---|---|---|---|---|---|
| JsonUtility | 支持 | 不支持 | 中 | 一般 | 源码默认方案,适合单机场景 |
| Newtonsoft.Json | 需要封装 | 支持 | 高 | 好 | 需要保存 Collider 子类信息时 |
| BinaryFormatter | 支持 | 支持 | 差 | 差 | 性能要求高且完全本地私有 |
| 自定义二进制 | 手动处理 | 手动 | 差 | 最好 | 数据量大、需要字段备注 |
这里要强调一个 JsonUtility 的坑:[Serializable]类里如果有interface字段或抽象类字段,运行时不报错,但保存会静默丢数据。所以原始工程在保存 Collider 信息时没有把Collider对象放进 JSON,而是拆出colliderType字符串、center、size、radius这些基础字段,重新组装。
3.3 保存模型编辑结果的完整数据流
保存操作的代码集中在ModelSceneDataStore中。整个流程为:遍历场景内所有带ModelRoot的 GameObject,取出文件相对路径和 Transform 数据,连同每个节点上的 Collider 描述写入SceneModelFile。写成文件后,又读取一次用于校验。
[Serializable] public class SceneModelFile { public List<SceneModelEntry> models = new List<SceneModelEntry>(); } [Serializable] public class SceneModelEntry { public string sourcePath; public Vector3 position; public Quaternion rotation; public Vector3 scale; public string colliderType; // Box / Sphere / Mesh public Vector3 colliderCenter; public Vector3 colliderSize; public float colliderRadius; }保存代码先收集数据,再通过File.WriteAllText写出:
public static void Save(string path, List<SceneModelEntry> entries) { SceneModelFile file = new SceneModelFile(); file.models = entries; string json = JsonUtility.ToJson(file, true); File.WriteAllText(path, json, Encoding.UTF8); }JsonUtility.ToJson第二个参数prettyPrint设为true,保存的文件方便人工 diff。读取时使用JsonUtility.FromJson<SceneModelFile>(text),然后生成ModelRoot并重新解析 OBJ,填充位置旋转缩放。
提示:Quaternion 直接序列化后是 x、y、z、w 四个 float,如果 JSON 需要给外部工具读,建议保存 Euler 值,但读取时要注意万向锁问题。源码工程默认存 Quaternion,因为 Unity 内部处理旋转更准确。
3.4 保存路径的权限和编码问题
保存路径不能直接用玩家可执行文件所在目录,Windows 下 Program Files 目录没有写入权限。工程里有一个RuntimeSavePath.GetPath(string relative)方法,优先返回Application.persistentDataPath,只在UNITY_EDITOR环境下返回Application.dataPath + "/ModelExports"。这保证 Play Mode 下能立刻在 Project 窗口看到导出文件,真机上也不会因为权限报错。
File.WriteAllText默认使用 UTF-8 无 BOM,但 OBJ 源文件可能是 ANSI 编码。读取时先用File.ReadAllBytes判断 BOM,如果没有 BOM 再尝试Encoding.Default,否则中文路径或模型内的中文备注容易变成乱码,导致f行解析失败。
4. 碰撞体信息的运行时生成:从 Mesh 到 Collider 的三种策略
4.1 无脑加 MeshCollider 会卡帧,必须做构建参数分级
直接把 Mesh 赋给 MeshCollider 是最简单的方法,但动态导入的模型没有经过 Unity 的资源导入管线,mesh.triangles没有生成包围体加速结构,首次物理计算会 carriyield 一次明显卡顿。源码工程把碰撞体生成策略分为三档:小型模型用 MeshCollider,中型用 BoxCollider 拟合,大型用凸包近似。
ColliderBuilder.AddCollider是入口,它根据用户选择的ColliderApproximation枚举生成不同组件。默认枚举值从None到ConvexMesh排列,这样 UI 下拉框可以直接绑定索引。
public enum ColliderApproximation { None, Box, Sphere, Mesh, ConvexMesh } public static Collider AddCollider(GameObject target, ColliderApproximation mode) { Mesh mesh = target.GetComponent<MeshFilter>().sharedMesh; Collider col = null; switch (mode) { case ColliderApproximation.Box: var box = target.AddComponent<BoxCollider>(); box.center = mesh.bounds.center; box.size = mesh.bounds.size; col = box; break; case ColliderApproximation.Sphere: var sphere = target.AddComponent<SphereCollider>(); sphere.center = mesh.bounds.center; sphere.radius = mesh.bounds.extents.magnitude; col = sphere; break; case ColliderApproximation.Mesh: var mc = target.AddComponent<MeshCollider>(); mc.sharedMesh = mesh; col = mc; break; case ColliderApproximation.ConvexMesh: var cc = target.AddComponent<MeshCollider>(); cc.sharedMesh = mesh; cc.convex = true; col = cc; break; } return col; }这段代码包含了碰撞体信息保存的重要前提:Box 和 Sphere 都是基于mesh.bounds,而不是renderer.bounds。因为Renderer.bounds是经过 Transform 缩放后的 AABB,实际写入 Collider 后 Unity 会再次与外层 Transform 相乘,造成双层缩放。使用mesh.bounds得到的 size 是建模空间数值,后续放在任何 scale 下都相对正确。
4.2 最小包围球算法,让 SphereCollider 更贴合模型
mesh.bounds.extents.magnitude是 AABB 对角线一半,对长条形模型会得到一个过大的包围球。更好的做法是用 Ritter 算法计算近似最小包围球,它迭代一次就能覆盖 95% 以上的顶点,代码也不复杂。
public static Sphere CalculateBoundingSphere(Vector3[] vertices) { Vector3 xmin = vertices[0], xmax = vertices[0]; Vector3 ymin = vertices[0], ymax = vertices[0]; Vector3 zmin = vertices[0], zmax = vertices[0]; foreach (Vector3 v in vertices) { if (v.x < xmin.x) xmin = v; if (v.x > xmax.x) xmax = v; if (v.y < ymin.y) ymin = v; if (v.y > ymax.y) ymax = v; if (v.z < zmin.z) zmin = v; if (v.z > zmax.z) zmax = v; } float dx = (xmax - xmin).sqrMagnitude; float dy = (ymax - ymin).sqrMagnitude; float dz = (zmax - zmin).sqrMagnitude; Vector3 minPoint = dx > dy && dx > dz ? xmin : dy > dz ? ymin : zmin; Vector3 maxPoint = dx > dy && dx > dz ? xmax : dy > dz ? ymax : zmax; Vector3 center = (minPoint + maxPoint) * 0.5f; float radius = Vector3.Distance(minPoint, maxPoint) * 0.5f; for (int i = 0; i < vertices.Length; i++) { float dist = Vector3.Distance(vertices[i], center); if (dist > radius) { Vector3 dir = (vertices[i] - center).normalized; center = (center + vertices[i]) * 0.5f; radius = Vector3.Distance(center, vertices[i]); } } return new Sphere(center, radius); }Ritter 算法的迭代部分在顶点序列极端分布时可能不是全局最优,但相比 AABB 外接球,它生成的半径明显更小。源码工程将这个Sphere结构体保存在SceneModelEntry.colliderRadius,读取时直接赋给SphereCollider.radius。如果你的模型是车辆、武器这类长条状物体,建议用 BoxCollider 而不是 Sphere,物理表现更接近真实形状。
4.3 碰撞体信息保存:哪些字段值得写进 JSON
运行时生成的 Collider 组件不能直接序列化,因为组件实例 ID 每次运行都不同。源码工程定义了一个ColliderInfo类,只保存支撑重建的必要参数:类型、中心、尺寸或半径、以及触发状态。它还处理了MeshCollider的特殊情况——保存convex标记,因为凸包碰撞体的物理解算开销远低于非凸。
public static ColliderInfo Extract(Collider col) { ColliderInfo info = new ColliderInfo(); if (col is BoxCollider box) { info.type = "Box"; info.center = box.center; info.size = box.size; } else if (col is SphereCollider sphere) { info.type = "Sphere"; info.center = sphere.center; info.radius = sphere.radius; } else if (col is MeshCollider mesh) { info.type = mesh.convex ? "ConvexMesh" : "Mesh"; info.center = Vector3.zero; info.radius = 0f; } return info; }这里有个容易忽略的点:Collider的center是相对自身坐标系的偏移,不是世界坐标。保存后再次加载到不同位置,直接赋值center即可,不需要额外转换。如果加载时原先的模型 scale 发生变化,而colliderSize保存的是建模空间数值,那么每次加载都要用 localScale 重新乘一次,否则碰撞体积会漂移。
提示:动态生成 MeshCollider 后,如果之后又编辑了模型顶点(比如窗口里的顶点拖拽),必须重新设置
sharedMesh或调用mesh.RecalculateBounds(),否则 Collider 仍使用旧三角面数据。源码工程在ModelEditor.EditVertex方法里主动通知ColliderBuilder.RefreshCollider,这是保证“编辑-保存-再加载”闭环一致性的关键。
5. 验证与排错:一个 OnDrawGizmos 检查整个导入链路
5.1 用 Gizmos 把 Collider 信息画出来
运行时看不到 Collider 边界,尤其是 MeshCollider 在 Scene 视图默认只显示线框。源码工程在ModelRoot组件上挂了一个ColliderDebugDrawer,利用OnDrawGizmos绘制包围盒与包围球,这样能快速对比模型导入后的mesh.bounds、实际 Collider 参数和 Transform 缩放是否一致。
private void OnDrawGizmosSelected() { BoxCollider box = GetComponent<BoxCollider>(); if (box == null) return; Gizmos.color = new Color(0.2f, 1f, 0.3f, 0.8f); Vector3 worldCenter = transform.TransformPoint(box.center); Vector3 worldSize = Vector3.Scale(transform.lossyScale, box.size); Gizmos.matrix = transform.localToWorldMatrix; Gizmos.DrawWireCube(box.center, box.size); }DrawWireCube的第二个参数要用box.size,而不是经过lossyScale累乘后的值,因为Gizmos.matrix已经把矩阵切到了局部坐标系。如果这时再用box.center * transform.lossyScale,画出来的框会叠加一层缩放。
验证场景通常在StreamingAssets放 3 个测试模型:一个纯立方体 OBJ、一个带法线且超过 10 万面的城市模型、一个没有vt只有顶点和人面的老模型。立方体用来验证基本导入旋转缩放是否按预期,大模型用来排查解析耗时和碰撞体卡顿,老模型用来检查法线缺失时的渲染表现。
如果导入后位置正确但旋转不对,先检查sourcePath的读取方向。有些项目把 OBJ 的 Y 轴和 Z 轴做了交换,源码工程保留了一个ForwardAxis配置,默认为 Unity 左手坐标系的 Z 正向,可以在ModelImportSetting里手动改成 Y 轴,这会直接驱动一个Quaternion.Euler(90, 0, 0)修正,而不是改顶点坐标。
最后一个小技巧:在UNITY_EDITOR环境下,把保存 JSON 的动作也注册到EditorApplication.quitting,这样即使 Play Mode 崩溃,工程退出前也会自动 dump 一份当前编辑数据。真机上则把同样的保存逻辑挂到OnApplicationPause,避免玩家切后台丢失一整个场景的模型布局。整套源码工程的核心价值就在这段插桩思维里,不管后续怎么扩展,永远保证“数据可落盘、状态可回溯、碰撞边界可看见”。
本文还有配套的精品资源,点击获取