ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity3D运行时OBJ模型导入与碰撞体生成完整实现

Unity3D运行时OBJ模型导入与碰撞体生成完整实现 简介面向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 或 GLTFUnity 引擎内置的类型在打包后并不保证能解析外部 FBXFBX 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); ListVector3 verts new ListVector3(); ListVector2 uvs new ListVector2(); ListVector3 normals new ListVector3(); Listint tris new Listint(); 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 ListSceneModelEntry models new ListSceneModelEntry(); } [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, ListSceneModelEntry 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.FromJsonSceneModelFile(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.GetComponentMeshFilter().sharedMesh; Collider col null; switch (mode) { case ColliderApproximation.Box: var box target.AddComponentBoxCollider(); box.center mesh.bounds.center; box.size mesh.bounds.size; col box; break; case ColliderApproximation.Sphere: var sphere target.AddComponentSphereCollider(); sphere.center mesh.bounds.center; sphere.radius mesh.bounds.extents.magnitude; col sphere; break; case ColliderApproximation.Mesh: var mc target.AddComponentMeshCollider(); mc.sharedMesh mesh; col mc; break; case ColliderApproximation.ConvexMesh: var cc target.AddComponentMeshCollider(); 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 GetComponentBoxCollider(); 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避免玩家切后台丢失一整个场景的模型布局。整套源码工程的核心价值就在这段插桩思维里不管后续怎么扩展永远保证“数据可落盘、状态可回溯、碰撞边界可看见”。本文还有配套的精品资源点击获取
返回列表