news 2026/9/23 18:44:23

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 或 GLTF

Unity 引擎内置的类型在打包后并不保证能解析外部 FBX,FBX SDK 涉及二进制版本和依赖项,放在运行时会有平台兼容问题。OBJ 是纯文本,顶点、法线、UV、三角面都以vvnvtf开头,格式公开且没有平台约束,所以这套工程的运行时导入入口以.obj为主,同时保留.txt.bytes的读取路径,便于把模型放在 StreamingAssets 或玩家目录下。

如果项目必须加载 FBX,一般做法是离线阶段先用 Unity 的 AssetImporter 转成 AssetBundle,运行时再UnityWebRequestAssetBundle.GetAssetBundle。但这会引入打包流程,无法做到“拿到文件立刻预览”。源码工程把两种方式都留了口子:OBJ 走运行时解析,FBX 走预打包接口,二者共用同一个ModelData中间结构,这样后续编辑和保存逻辑不需要区分来源。

2.2 解析流程:从 FileStream 到 Mesh 的关键处理

核心入口是一个静态方法ObjFileImporter.Import(string path),它负责读取文件字节、按行解析,并最终返回ModelDataModelData保存了verticesuvsnormalstrianglesmaterials以及模型包围盒,而不是直接返回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。二是没有提取vnvt,直接RecalculateNormals会丢失原文件的光滑组信息,对圆角、斜面这类模型表现差异很大。如果要做严谨导入,应该把f行里的v/vt/vn三个索引分别拆开,并处理索引不相同的情况;源码工程原版为兼容性刻意简化了这块,但留了#if OBJ_USE_EXACT_NORMAL的宏定义开关,打开后才会走完整法线映射。

2.3 加载后的单位与坐标系对齐

OBJ 文件常见的单位是厘米,Unity 默认场景单位是米。直接导入会出现模型在场景里放大 100 倍或缩小的错觉。源码工程在Import方法后接了一个ModelScaleUtility.ApplyUnitRatio(transform, ratio),默认按1f处理,但编辑器界面提供0.01f0.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结构体,包含PositionRotationScale三项,每次拖拽开始时用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,原因是它不依赖第三方库,且能直接序列化Vector3Quaternion等 Unity 原生类型。缺点是字段名固定,不能加注释,不支持多态,这一点在保存 Collider 类型时会暴露。对比见下表。

方案Unity 原生类型支持多态支持可读性版本兼容推荐场景
JsonUtility支持不支持一般源码默认方案,适合单机场景
Newtonsoft.Json需要封装支持需要保存 Collider 子类信息时
BinaryFormatter支持支持性能要求高且完全本地私有
自定义二进制手动处理手动最好数据量大、需要字段备注

这里要强调一个 JsonUtility 的坑:[Serializable]类里如果有interface字段或抽象类字段,运行时不报错,但保存会静默丢数据。所以原始工程在保存 Collider 信息时没有把Collider对象放进 JSON,而是拆出colliderType字符串、centersizeradius这些基础字段,重新组装。

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枚举生成不同组件。默认枚举值从NoneConvexMesh排列,这样 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; }

这里有个容易忽略的点:Collidercenter是相对自身坐标系的偏移,不是世界坐标。保存后再次加载到不同位置,直接赋值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,避免玩家切后台丢失一整个场景的模型布局。整套源码工程的核心价值就在这段插桩思维里,不管后续怎么扩展,永远保证“数据可落盘、状态可回溯、碰撞边界可看见”。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/23 18:43:31

Dart SDK 中 FFI 基准测试原生库的构建与 CIPD 发布流程指南

编程语言编译器语言运行时标准库开发工具 【免费下载链接】sdk The Dart SDK, including the VM, JS and Wasm compilers, analysis, core libraries, and more. 项目地址&#xff1a; https://gitcode.com/gh_mirrors/sdk1/sdk 点击查看 免费下载 本文以 Dart SDK 仓库中的 b…

作者头像 李华
网站建设 2026/9/23 18:43:07

高分遥感语义分割实战:PyTorch实现地物分类与面积估算全流程

简介&#xff1a;这是一份面向遥感与计算机视觉学习者的项目实践资源&#xff0c;以PyTorch为基础实现高分遥感影像语义分割&#xff0c;解决地物分类任务。资源基于GF2影像样本数据&#xff0c;覆盖模型设计、数据加载、训练验证与推理预测全流程&#xff0c;并重点展开膨胀预…

作者头像 李华
网站建设 2026/9/23 18:42:16

G6 节点(Node)体系全解析:内置类型、数据结构与样式配置实战

数据可视化前端图表库 【免费下载链接】G6 ♾ A Graph Visualization Framework in JavaScript. 项目地址&#xff1a; https://gitcode.com/gh_mirrors/g6/G6 点击查看 免费下载 节点是图可视化中最核心的构成单元。本文以 G6 官方文档《节点总览》为主线&#xff0c;结合 G6…

作者头像 李华
网站建设 2026/9/23 18:41:14

BUCK电路环路补偿设计与Saber仿真验证:从传递函数到相位裕量

简介&#xff1a;这是一份面向开关电源研发工程师的环路设计专题资料&#xff0c;聚焦BUCK电路从环路计算、补偿参数设计到仿真验证的完整流程。资料从自动控制理论中的乃奎斯特稳定性判据切入&#xff0c;讲解穿越频率、相位裕量、增益裕量、静态增益与动态响应等关键概念&…

作者头像 李华
网站建设 2026/9/23 18:40:21

单通道脑电睡眠分期实战:Python从EDF到分类模型

简介&#xff1a;这份资源面向计算机、人工智能、通信工程、自动化、电子信息等专业的在校学生与教师&#xff0c;以及希望入门生理信号处理的企业员工&#xff0c;提供一套基于单通道脑电信号实现自动睡眠分期的完整Python项目。项目围绕EEG信号预处理、数据集构建、深度网络建…

作者头像 李华
网站建设 2026/9/23 18:38:55

用C#解析STEP文件:从ISO-10303-21文本到B-Rep拓扑提取

简介&#xff1a;基于C#的STEP文件解析器完整源码与项目说明&#xff0c;属于本科毕设项目&#xff0c;主要面向计算机相关专业毕业生及需要工程实战的C#学习者。项目围绕STEP中性文件解析展开&#xff0c;实现了对文件中各组成元素的类型识别、详细信息提取&#xff0c;以及拓…

作者头像 李华