在 Unity 里做数字孪生时,真正耗时的往往不是业务逻辑,而是把大型 CAD 模型正确搬进场景。尤其是产线、机器人、机床这类大型装配体,手工导入不仅容易丢结构,还会因为单位、坐标系、材质和碰撞体设置不一致,导致后期调试 realvirtual 和 PLC 仿真时反复返工。realvirtual 作为 Unity 生态中常用于虚拟调试和数字孪生搭建的平台,本身提供了运行时框架,但 CAD 到 Unity 的自动化导入仍然需要在编辑器阶段处理干净。
这篇内容围绕 realvirtual 的数字孪生开发流程,整理一条从 CAD 数据到 Unity 场景的自动化导入链路:先在 CAD 侧或 DCC 工具中完成单位、层级、格式预处理,再用 Unity Editor 脚本批量设置导入参数并实例化到场景,最后用命名映射挂接 realvirtual 行为组件,并完成验证。读完你会得到一套可执行的自动化基线,后面可以按项目扩展成与 PDM、版本管理、构建流水线对接的完整方案。
1. 先理清 realvirtual 平台的 CAD 导入链路
1.1 CAD 原生文件为什么不能直接进 Unity
Unity 运行时只能理解网格、材质、Transform 和组件。而原生 CAD 文件记录的是参数化特征:拉伸、旋转、草图、约束、特征树。这些数据对建模软件有意义,对 Unity 来说没有直接用途。更麻烦的是,大型装配体通常包含大量外部引用,直接从某个子部件文件导入,经常会缺件或丢失装配位置。
CAD 原生格式还有第二个问题:它不是为实时渲染设计的。一个螺栓模型可能带有倒角、螺纹、孔特征,转换后会产生非常高密度的三角网格,Unity 的 Mesh Collider 计算和渲染压力都会大幅上升。realvirtual 做的是数字孪生和虚拟调试,它需要的是能在场景中实时运行、能被驱动组件控制、能被碰撞体识别的对象,而不是一份完整的设计图。
所以,CAD 导入的第一步不是“把文件拖进去”,而是确认数据在哪个环节被转换成 Unity 能用的网格资源。原生 CAD 文件、STEP/IGES 这类几何交换格式,应该停留在预处理阶段;进入 Assets 的应该是 FBX、glTF、OBJ 这类通用格式。
1.2 推荐的链路和格式选择
在 realvirtual 项目中,推荐走这条链路:
原生 CAD 设计软件 -> 导出中性格式或网格格式 -> DCC 工具批量整理单位、层级、命名 -> 导出 FBX / glTF -> Unity 导入管线 -> realvirtual 组件装配不同格式在链路中的角色差别很大,整理成表更直观。
| 数据格式 | 适合阶段 | 保留信息 | 在 Unity 中的可用性 | 备注 |
|---|---|---|---|---|
| 原生 CAD 文件 | 设计阶段 | 参数特征、装配约束 | 不能直接读 | 需要设计软件或专用导出器 |
| STEP / IGES | 几何交换 | 实体几何、装配结构 | 不能直接读 | 需要先转换成网格格式 |
| OBJ / STL | 通用网格 | 网格,OBJ 可带材质 | 可导入 | 层级、单位、材质支持弱 |
| FBX | Unity 常用 | 网格、层级、材质、动画 | 推荐 | 对 Unity 支持成熟 |
| glTF | Web/轻量场景 | 网格、层级、材质 | 可导入 | 适合程序化生成和 Web 展示 |
如果原始 CAD 软件能直接导出 FBX,优先导出 FBX。如果只能导出 STEP/IGES,则要先转入 Blender 或其他 DCC 工具,再导出 FBX。OBJ 和 STL 不是不能用,但它们对层级支持太弱,适合单件模型或碰撞代理网格,不适合整条产线装配体。
1.3 数字孪生场景下,CAD 模型不只是“看得见”
realvirtual 的价值在于用 Unity 的实时渲染和物理仿真去验证控制逻辑。一个机器人单元需要的不是单一完整网格,而是拆分成多个运动部件,并且每个部件要能被驱动、被传感、被碰撞检测。
如果直接把整个装配体导成一个 Mesh,运动仿真根本无法做。所以自动化导入的核心不只是文件复制,而是:
- 拆分运动部件。
- 设定层级和原点。
- 挂接驱动组件。
- 建立命名映射。
- 生成适合物理引擎的碰撞体。
CAD 几何只是数字孪生模型的外壳,真正的行为信息要通过 Unity 组件补充。这也是为什么“自动导入 CAD”和“自动生成可运行数字孪生场景”之间还有一段距离。把这段距离用脚本和清单文件填平,是本文的关键目标。
2. 自动化预处理:先统一单位、坐标轴和层级
2.1 单位、原点、坐标轴为什么会影响物理仿真
Unity 的默认单位是米。CAD 软件常用毫米,部分行业用英寸。如果不统一,一个 5000 mm 长的设备在 Unity 里会变成 5000 个 Unity 单位,物理引擎会把模型当成巨大物体,刚体下落看似正常,但碰撞检测参数、关节力矩、传感器范围都会受影响。realvirtual 中的速度、加速度、位置反馈通常也按米每秒或弧度处理,单位错乱会直接污染仿真数据。
原点同样重要。CAD 装配体原点有时在设备角落,有时在基座底面,有时还在整个世界坐标的几公里之外。如果导入后原点位置不统一,脚本里设置position就会非常困难,所有模型都要手工平移回目标位置。
坐标轴方向也经常踩坑。很多 CAD 软件使用 Z 轴作为高度方向,Unity 默认 Y 轴向上。如果导出时不做转换,模型进入 Unity 后会整体旋转 90 度。这类问题不能在场景里手动旋转修正,因为后续 realvirtual 驱动部件时,旋转方向会一并受影响。
2.2 用 Blender Python 做批量模型整理
如果模型数量多,不建议手工打开软件逐个另存。Blender 支持 Python API,可以在不开图形界面的情况下批量转换。下面的脚本演示一个最小流程:清空场景、导入模型、设置缩放、导出 FBX。
import bpy import os input_dir = r"D:\cad_work\meshes" output_dir = r"D:\cad_work\fbx" unit_scale = 0.001 # 从毫米转到米 if not os.path.exists(output_dir): os.makedirs(output_dir) for file_name in os.listdir(input_dir): if not file_name.lower().endswith((".obj", ".stl")): continue # 清空当前场景,保持每次导入独立 bpy.ops.wm.read_factory_settings(use_empty=True) src_path = os.path.join(input_dir, file_name) if file_name.lower().endswith(".obj"): bpy.ops.wm.obj_import(filepath=src_path) else: bpy.ops.wm.stl_import(filepath=src_path) # 统一设置所有对象缩放到米 for obj in bpy.context.scene.objects: obj.scale = (unit_scale, unit_scale, unit_scale) export_name = os.path.splitext(file_name)[0] + ".fbx" bpy.ops.export_scene.fbx(filepath=os.path.join(output_dir, export_name))不同 Blender 版本中导入导出算子名可能不同。Blender 4.x 使用wm.obj_import,旧版本使用import_scene.obj。脚本重点不是某个 API,而是整个批处理思路:每次清空、导入、设置缩放、导出,保证一批模型的单位、坐标和文件命名规则一致。
如果你的 CAD 软件能直接导出 FBX 且层级正确,Blender 这一步可以省略。但现实中经常遇到 CAD 导出结果混乱,这时用 Blender 统一处理会比在 Unity 里逐个修正更可靠。
2.3 清理模型和生成 LOD,避免把设计细节直接带进实时场景
CAD 里一个螺栓可能有几十条倒角边,导入后会在场景中生成大量三角形。实时场景不需要全部保留,尤其 realvirtual 做控制逻辑验证时,很多细节对逻辑没有意义。
常见做法有:
- 对固定不动的结构件生成低模或使用简化代理。
- 对需要真实碰撞运动的部分,碰撞体使用简单盒体、圆柱或凸包。
- 对摄像头观察不到的内部零件可以删除。
- 对距离远、体积小的零件使用 LOD Group 或减少实例数量。
生成 LOD 可以使用 Unity 的 LOD Group,也可以在 Blender 中用 Decimate 修改器预处理。核心原则是:视觉高模、碰撞低模、逻辑传动部件用简化网格。这样可以在保持画面可读性的同时,明显降低渲染和物理开销。
2.4 预处理检查清单
在进入 Unity 之前,建议按下面的清单逐项检查:
| 检查项 | 要求 | 常见问题 |
|---|---|---|
| 单位 | 统一为米,或在导出时记录缩放值 | 模型过大、碰撞异常 |
| 原点 | 每个部件原点在运动旋转轴或易于定位的位置 | 需要手工大幅平移 |
| 坐标轴 | 高度方向符合 Unity Y 轴向上的约定 | 模型整体旋转 90 度 |
| 层级 | 运动部件独立,不能全部合并成一个 Mesh | realvirtual 无法识别运动对象 |
| 命名 | 每个部件名称清晰且可映射 | 出现大量 Part_001 随机名 |
| 材质 | 贴图使用相对路径,避免中文路径和空格 | 导入后材质丢失 |
| 三角面 | 导出前检查总三角形数量 | 场景卡顿、DrawCall 失控 |
3. Unity Editor 脚本:把 CAD 资源自动化转换为数字孪生场景
3.1 目录结构和清单文件先定好
在 Assets 下建议创建以下目录结构:
Assets/ └── ImportedCAD/ ├── FBX/ # 原始 FBX 资源 ├── Generated/ # 生成的 Prefab 和材质 ├── Manifests/ # JSON 映射清单 └── Scripts/ # 编辑器导入脚本同时创建一份manifest.json,记录每个模型的导入位置、旋转、缩放、命名映射和父节点。这样既方便重复导入,也方便核查。
{ "entries": [ { "name": "Conveyor_01", "fbx": "Assets/ImportedCAD/FBX/Conveyor_01.fbx", "virtualTag": "conveyor_01", "parentPath": "ProductionLine/RobotCell_01", "position": [10.0, 0.0, 0.0], "rotation": [0.0, 90.0, 0.0], "scale": [1.0, 1.0, 1.0] }, { "name": "Robot_Arm_01", "fbx": "Assets/ImportedCAD/FBX/Robot_Arm_01.fbx", "virtualTag": "robot_arm_01", "parentPath": "ProductionLine/RobotCell_01", "position": [0.0, 0.0, 5.0], "rotation": [0.0, 0.0, 0.0], "scale": [1.0, 1.0, 1.0] } ] }如果不希望手工维护 JSON,也可以在 CAD 侧生成同名映射文本。核心是让脚本能自动读取清单,而不是在 Unity 场景里手工配置每个设备。
3.2 批量设置 FBX 导入参数
CAD 模型导入 Unity 时,默认导入参数不一定合适。摄像机和灯光标记对 CAD 模型大多无用,关闭它们可以减少资源体积。BlendShape、可见性标记也往往不是 CAD 数据需要的。
下面这段编辑器脚本可以批量设置指定目录下所有 FBX 的导入参数。
using UnityEditor; using UnityEngine; public static class CadFbxImporter { [MenuItem("Tools/CAD Import/Apply FBX Import Settings")] public static void ApplyFbxImportSettings() { string[] guids = AssetDatabase.FindAssets("t:Model", new[] { "Assets/ImportedCAD/FBX" }); foreach (string guid in guids) { string path = AssetDatabase.GUIDToAssetPath(guid); ModelImporter importer = AssetImporter.GetAtPath(path) as ModelImporter; if (importer == null) continue; importer.globalScale = 1.0f; importer.useFileScale = true; importer.importCameras = false; importer.importLights = false; importer.importVisibility = false; importer.importBlendShapes = false; importer.isReadable = false; importer.SaveAndReimport(); } AssetDatabase.Refresh(); } }这段脚本的作用是把导入参数标准化。globalScale = 1的前提是预处理阶段已经统一了单位;如果 FBX 导出时已经做了毫米转米,则不需要在 Unity 里再缩放一次。isReadable = false可以降低运行时内存占用,但如果后续需要读取网格顶点数据,则需要保留可读。
需要注意,不同 Unity 版本的ModelImporter参数名可能不同,尤其是材质导入模式。如果项目使用 URP 或 HDRP,还需要处理材质转换,否则导入后可能出现粉色材质。
3.3 根据 manifest 自动实例化到场景
批量导入资源后,还需要把 FBX 预制体加载到场景,并按清单设置位置、层级、名称和 realvirtual 组件。下面脚本实现了一个最小的 manifest 导入流程。
using System; using System.IO; using UnityEditor; using UnityEngine; public class CadImportEntry { public string name; public string fbxPath; public string virtualTag; public string parentPath; public float[] position; public float[] rotation; public float[] scale; } public class CadImportManifest { public CadImportEntry[] entries; } public static class CadAutoImporter { [MenuItem("Tools/CAD Import/Import From Manifest")] public static void ImportFromManifest() { string manifestPath = EditorUtility.OpenFilePanel("选择 CAD 清单", Application.dataPath, "json"); if (string.IsNullOrEmpty(manifestPath)) return; string json = File.ReadAllText(manifestPath); CadImportManifest manifest = JsonUtility.FromJson<CadImportManifest>(json); if (manifest == null || manifest.entries == null) { Debug.LogError("清单格式不正确"); return; } Transform root = GameObject.Find("CAD_Root")?.transform; if (root == null) { root = new GameObject("CAD_Root").transform; } foreach (CadImportEntry entry in manifest.entries) { ImportEntry(entry, root); } AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); } private static void ImportEntry(CadImportEntry entry, Transform root) { GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(entry.fbxPath); if (prefab == null) { Debug.LogWarning("FBX 不存在或尚未导入: " + entry.fbxPath); return; } GameObject instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab); instance.name = entry.name; instance.transform.SetParent(root, true); instance.transform.localPosition = ToVector3(entry.position); instance.transform.localRotation = Quaternion.Euler(ToVector3(entry.rotation)); instance.transform.localScale = ToVector3(entry.scale); if (!string.IsNullOrEmpty(entry.parentPath)) { Transform parent = GameObject.Find(entry.parentPath)?.transform; if (parent != null) { instance.transform.SetParent(parent, true); instance.transform.localPosition = ToVector3(entry.position); instance.transform.localRotation = Quaternion.Euler(ToVector3(entry.rotation)); instance.transform.localScale = ToVector3(entry.scale); } else { Debug.LogWarning("父节点未找到: " + entry.parentPath); } } AttachRealvirtualDriver(instance, entry.virtualTag); } private static Vector3 ToVector3(float[] values) { if (values == null || values.Length < 3) return Vector3.zero; return new Vector3(values[0], values[1], values[2]); } private static void AttachRealvirtualDriver(GameObject target, string virtualTag) { if (string.IsNullOrEmpty(virtualTag)) return; Type driverType = Type.GetType("realvirtual.Core.Drive, realvirtual.Core"); if (driverType == null) { Debug.LogWarning($"当前 realvirtual 版本中未找到 Drive 类型: {virtualTag}"); return; } Component driver = target.GetComponent(driverType); if (driver == null) driver = target.AddComponent(driverType); var prop = driverType.GetProperty("Name"); if (prop != null) prop.SetValue(driver, virtualTag); } }这段脚本使用反射挂接 realvirtual 组件,是因为不同版本中组件类名和命名空间可能不同。反射可以让脚本在版本不确定时不至于编译失败,但代价是不方便 IDE 补全和编译期检查。实际项目中,如果确认了 realvirtual 版本,可以直接在脚本顶部引用命名空间,用强类型方式编写,便于维护。
脚本中的parentPath需要在场景中已经存在对应对象。如果父对象由其他批次导入,要保证导入顺序正确,或者在父对象不存在时不报错,只打印 warning。
3.4 如何把“运动部件”自动挂接成 realvirtual 可以驱动的对象
CAD 几何导入后,realvirtual 需要知道哪些对象是“可驱动的”。驱动可以是传送带的运动、机器人关节的旋转、夹具的开关等。这个信息无法从 FBX 文件自动推断,必须通过命名规则或清单映射显式声明。
实现思路有两种:
- 在 CAD 导出前,给需要运动的部件加固定前缀,例如
DRV_Conveyor_01、JNT_Robot_Axis1。脚本遍历根节点下所有子物体,按前缀查找并挂接驱动组件。 - 在 manifest.json 中维护
virtualTag字段,脚本按名称或标签查找目标对象,再挂接组件。
第一种方式更接近自动化,但要求 CAD 设计人员遵守命名规范。第二种方式更灵活,适合已有模型命名混乱的场景。实际项目中,可以把两者结合:脚本先按前缀识别,再读 JSON 中的显式映射。
驱动组件应该挂在运动部件的根节点上,而不是挂在整台设备的总根节点上。这样 realvirtual 才能单独控制该部件的运动,而不会把位姿叠加到错误层级。
4. 导入后的验证:从“模型显示出来”到“仿真能跑起来”
4.1 场景基本检查
打开 Unity 场景后,首先要检查模型的 Transform。正常数字孪生模型的 localPosition 和 localScale 应该是可控范围内的数值,不会出现几十万单位和巨大的缩放值。
选择根节点下的子物体,打开 Inspector,检查 Mesh Filter 和 Mesh Renderer 是否正常。如果模型只是一堆节点,没有 Renderer,说明导入时网格没有正确生成,或者 FBX 文件本身缺少几何信息。
也可以用编辑器脚本批量打印模型的包围盒,快速发现异常点。
using UnityEditor; using UnityEngine; public static class CadBoundsValidator { [MenuItem("Tools/CAD Import/Validate Model Bounds")] public static void ValidateBounds() { MeshRenderer[] renderers = Object.FindObjectsOfType<MeshRenderer>(); foreach (MeshRenderer renderer in renderers) { Bounds b = renderer.bounds; if (b.size.magnitude > 1000f) { Debug.LogWarning($"{renderer.name} 包围盒尺寸过大 {b.size}", renderer); } } } }这个脚本只是辅助定位问题。实际执行时,还要检查是否有模型缺失、空节点、重复网格等问题。
4.2 网格性能和运行时验证
大型 CAD 导入后最典型的表现是场景卡顿。进入 Play 模式后,可以打开 Game 窗口右上角的 Stats 面板,查看 DrawCall、三角形数量、SetPassCalls 等指标。
如果场景中有多个高精度设备,不要单一追求视觉精度。实际项目通常会为机械运动件保留精细网格,为不参与运动的位置使用 LOD 或剔除。物理仿真的碰撞体也不必和视觉网格完全一致,使用 Box Collider、Capsule Collider 或凸包能显著降低物理计算压力。
建议记录导入前后的性能数据,用于验证自动化脚本的收益。例如:
| 指标 | 导入前状态 | 自动化后状态 | 目标 |
|---|---|---|---|
| 总三角形数量 | 需要手工统计 | 脚本输出报告 | 控制在目标平台预算内 |
| DrawCall 数量 | 不确定 | 按材质合并观察 | 不随模型数量线性增长 |
| 碰撞体数量 | 未配置 | 自动生成基础碰撞体 | 后续按运动需求手工微调 |
| 导入耗时 | 每人几小时 | 脚本几分钟 | 可重复执行 |
4.3 realvirtual 信号联动验证
模型能显示不代表 realvirtual 链路通。进入 Play 模式后,找到挂有驱动组件的对象,使用 realvirtual 的信号列表或自带仿真面板给一个测试值,观察部件是否按预期运动。
例如给传送带驱动一个速度值,给机器人关节一个角度值,确认方向、单位、运动范围都正确。如果对象没有反应,回到 Inspector 检查:
- 组件是否成功挂接。
- 信号名是否与虚拟标签匹配。
- 驱动类型是否和运动方式匹配。
- 物体层级是否允许被驱动组件控制。
不要只在编辑器里看到模型就认为导入完成。对数字孪生项目来说,真实标准是“外部信号能否驱动模型”。
4.4 验证清单
| 检查项 | 操作 | 通过标准 |
|---|---|---|
| 单位 | 查看模型 import scale 和 Transform scale | 1 米对应 Unity 1 单位 |
| 坐标轴 | 旋转设备,观察向上方向 | 符合项目约定 |
| 层级 | 展开根节点 | 运动部件独立存在,没有被合并 |
| 碰撞体 | 选中设备,查看 Collider | 碰撞体基本贴合,且物理运动不漂移 |
| realvirtual 组件 | 查看驱动组件 Inspector | 信号名存在,能与 PLC 信号对应 |
| 性能 | Game 窗口 Stats | 三角形和 DrawCall 符合目标预算 |
5. 大型 CAD 导入典型问题与排查链路
5.1 模型巨大、物理异常:单位没有统一
现象:设备在场景中显示很大,地面距离很远,刚体下落缓慢或抖动。
可能原因:CAD 使用毫米单位,FBX 导入时没有把缩放值设置为 0.001,或者预处理脚本没有生效。
检查方式:选中模型,看 Transform scale 和 ModelImporter globalScale。如果 localScale 是 1000,说明单位没有在预处理阶段处理好。
处理建议:重新回到预处理脚本,统一设置缩放,再导出 FBX。不要直接在 Unity 场景里把 scale 改成 0.001,因为后续每个子物体、碰撞体、物理参数都会受影响。
5.2 模型整体旋转 90 度:坐标系约定不一致
现象:模型进入 Unity 后整体侧躺,或者旋转轴指向错误方向