做机械拆装类交互,真正花时间的从来不是拖拽代码,而是"判定一个零件到底有没有装到位"这件事。早几年接一个发动机拆装培训项目,我花了一周去解决"活塞能不能被正确地装回缸套"这个看起来极其简单的需求。位置偏一点要给提示,角度差一点要让用户能转过来,装正了还要有吸附反馈。这套交互表面看就是"拖拽+吸附"四个字,真拆开做,至少要规划成五个环节。这篇文章就用一个简化版的发动机模型,把完整流程走一遍,代码基本可以直接复制到工程里跑。
这里先点明一个核心概念:交互式装配不是简单地把零件拖过去就行,而是要让系统知道"当前这个零件处于什么状态、距离目标位姿还有多远、是否允许被操作"。这三个问题的答案,直接决定你的项目是一个玩具级Demo,还是一个能交付的生产工具。
这篇文章适合三类人:想做机械培训、数字孪生拆装演示的开发者;需要Unity面试作品或毕设,想找一个完整交互案例的学生;以及已经会基础拖拽,想进阶到工业级交互逻辑的工程师。发动机模型只是载体,把逻辑理清之后,这套代码换到变速器、压缩机、家具组装上都能复用。
1. 先把拆装逻辑理清:一个零件从拿起装回要经历什么
1.1 交互式装配必须回答的三个问题
第一次接触机械拆装的人,第一反应通常是"这不就是拖拽脚本吗"——鼠标点一个零件,拖到目标位置,一松开就完事。如果你的场景只有两个零件,那确实是这样。但发动机这类多零件模型一进来,事情就会迅速变复杂。
第一个问题:零件能不能被操作?发动机不是所有零件都允许随意拆装,很多时候必须按顺序操作。比如要先拆掉气门室盖,才能碰到里面的气门机构。如果不管顺序,让用户直接拽底下的曲轴,演示就失真了,教学培训场景里尤其不能接受。
第二个问题:零件当前处于什么状态?它处于"已装配"还是"未装配",决定了它能否被拾取、吸附逻辑是否要触发。如果零件已经在装配位置,你再拖它,它应该从装配状态切换成松散状态,或者干脆禁止拖拽。状态管理不做清楚,后面会出现一堆莫名其妙的Bug。
第三个问题:零件装到什么程度算"到位"?这就是装配判定的核心。位置差多少允许吸附?角度差多少允许扣合?阈值太大,会出现"歪着也装进去了"的假象;阈值太小,用户拼了命也放不进去,体验直接崩盘。
这三个问题落到实现上,就是一套五步流程:数据登记、拾取高亮、拖拽移动、到位判定、流程控制。下面每个环节都会给代码和实践建议。
1.2 五步流程的依赖关系
| 步骤 | 核心任务 | 关键产出 |
|---|---|---|
| 第1步 | 零件装配数据登记 | 每个零件的目标位姿、顺序、提示文案 |
| 第2步 | 拾取与高亮 | 射线检测方案、可交互Layer、视觉反馈 |
| 第3步 | 拖拽移动 | 稳定的深度保持、旋转辅助操作 |
| 第4步 | 到位判定与自动吸附 | 距离+角度双阈值、吸附过渡动画 |
| 第5步 | 流程状态机 | 拆装顺序控制、步骤UI提示 |
这个顺序不能乱。第1步的数据是第4步判定是否成立的依据,第5步又反过来限制第2步拾取的对象。我见过不少项目一上来就写拖拽,写到一半发现装配判定完全没法做,最后全部推翻重来。所以哪怕你的需求再赶,也建议先按这个顺序把框架搭好。
2. 模型准备:发动机层级怎么组织才不会后期改到崩溃
2.1 层级树与命名规范
如果你的发动机模型是从外部软件导出的FBX,很可能所有零件都是平铺的,名字长成Cube_001、Cube_002这种。这种模型在零件拾取和数据登记阶段会把人逼疯。
强烈建议在Unity里重新组织一套干净的层级:
EngineRoot (空物体,挂AssemblyManager) ├── EngineBlock # 机体,固定件,不参与拆装 ├── Parts (空物体,存放所有可拆零件) │ ├── CylinderHead # 气缸盖 │ ├── ValveCover # 气门室盖 │ ├── Crankshaft # 曲轴 │ ├── Piston_01 # 活塞 │ ├── Piston_02 # 活塞 │ └── ... └── TargetPoints (空物体,存放装配目标位姿) ├── CylinderHead_Target ├── ValveCover_Target ├── Crankshaft_Target └── ...这种结构的优点很明显:每个零件只需要挂一个PartController脚本,目标点统一放在TargetPoints层级下,AssemblyManager遍历查找起来非常方便。零件名和目标点名通过后缀_Target对应,代码里用partKey做匹配,不依赖Inspector手工拖引用,配置几十个零件也不会乱。
2.2 枢轴点与参考点:吸附判定的隐藏前提
做装配判定时,我们比较的是零件自身Transform和目标点Transform之间的距离与角度。这个比较是否成立,取决于一个关键前提:零件模型的自身原点必须是装配基准点。
如果建模时所有零件都是独立文件、原点都在世界原点,导入Unity后直接拖到Parts下,就会出现一个非常隐蔽的坑:零件在场景里的位置看起来是对的,但它的局部坐标偏移量很大,而且枢轴点根本不在零件中心。这时候你按位置吸附,系统永远认为零件"差得很远"。
正确的做法有两种。第一种,在建模软件里把每个零件的原点挪到它与机体的装配基准面中心,重新导出。这是最规范的方式,适合对模型有完全控制权的情况。
第二种,不想回建模软件,就在Unity里给每个零件下挂一个空子物体,把这个空物体放到正确的装配基准点上,让PartController持有这个ReferencePoint的引用,所有判定都用"零件参考点vs目标点"来做。这个方法不用改模型,外来模型应急时很管用。
我自己在实际项目中更常用第二种,因为模型常常是外包团队给的,改原点要来回沟通,不如在Unity里挂一个参考点省事。但无论用哪种,都要在项目一开始就统一,不然后面排查起来非常痛苦。
2.3 碰撞体与刚体配置
可拆零件必须同时挂Collider和Rigidbody。
Collider的选择,我推荐BoxCollider或CapsuleCollider,而不是MeshCollider。原因后面避坑章节会细讲,这里先说结论:MeshCollider做精确碰撞可以,但物理开销和射线检测的消耗都更大,而且在非凸网格上做拖拽很容易出现抖动。发动机的大多数零件是规则几何体,用几个BoxCollider叠加来模拟轮廓,性能和安全度都是最优的。
Rigidbody的配置也有讲究。在拆装场景里,Rigidbody主要不是为了做真实物理仿真,而是提供被射线检测命中的载体,并为将来可能的掉落效果保留能力。所以建议这样设置:
useGravity = false:防止零件在拆下来的一瞬间往下掉。如果没有地面,零件会无限下落,最后掉出场景再也捡不回来。isKinematic = true:拖拽时不会被物理引擎干扰,也不会因碰撞被弹飞。
如果你做的是自由物理体验,想看到零件被拆下来后有掉落感,那可以把重力打开,但一定要加一个"掉落到阈值以下自动复位"的保护逻辑。
3. 第一步,数据登记:让零件记住自己应该装在哪
3.1 装配数据用ScriptableObject还是普通字段
接下来进入正式五步流程的第一步:数据登记。
装配目标位置和角度怎么存?最直观的方案是在PartController上公开两个字段,一个Transform类型的目标点引用,直接在Inspector里拖。这个方案在小场景里完全够用,三五个零件手动拖一下没问题。但发动机有几十个零件时,每个零件都要拖一次引用,繁琐且容易错。
更推荐用ScriptableObject把每个零件的装配数据独立成资产文件,在Inspector配置一次之后反复使用,还能配合编辑器脚本批量生成。
但这里有个Unity的硬性限制:ScriptableObject是资源资产,不能直接引用场景中的物体。所以SO里不存场景对象的引用,只存关键数据和标识,运行时由AssemblyManager通过partKey去场景里匹配目标点。
PartData的定义如下:
using UnityEngine; [CreateAssetMenu(fileName = "PartData", menuName = "Mechanical Assembly/Part Data")] public class PartData : ScriptableObject { public string partKey; // 零件标识,与场景零件名对应 public string displayName; // 显示名称,用于UI public Vector3 targetLocalPosition; // 目标零件在父节点下的局部位置 public Vector3 targetLocalEuler; // 目标局部欧拉角 [TextArea] public string hintText; // 装配提示文案 public bool canDisassemble = true; // 是否允许拆装 }这里最有讲究的是targetLocalPosition和targetLocalEuler存的到底是相对谁的偏移。我建议统一存"相对发动机根节点(EngineRoot)的局部偏移"。为什么不用世界坐标?因为整个发动机会作为整体被旋转、移动。如果存世界坐标,发动机一转,所有装配目标点全部失效。用父节点局部坐标,无论发动机在场景里怎么摆放,判定的相对关系都保持不变。
3.2 一个高效的位姿采集工具
装配目标点不会凭空生成。你当然可以手动在场景里摆放空物体,再把位置和角度填进PartData,但几十个零件这样操作,效率太低了,而且容易手抖填错。
更实用的做法是:先把零件按照正确的装配位姿摆好(一般在建模软件里或者Unity里用坐标微调),然后运行一个编辑器工具,一键记录所有零件的当前局部位姿,并写入对应的PartData资产。
我通常会在AssemblyManager上放一个自定义Editor按钮:
using UnityEditor; using UnityEngine; [CustomEditor(typeof(AssemblyManager))] public class AssemblyManagerEditor : Editor { public override void OnInspectorGUI() { DrawDefaultInspector(); AssemblyManager manager = (AssemblyManager)target; if (GUILayout.Button("采集所有零件当前位姿到目标点")) { manager.CaptureAllPartTargets(); EditorUtility.SetDirty(manager); } } }CaptureAllPartTargets的实现思路是:遍历Parts目录下所有挂PartController的子物体,读取transform.localPosition和localEulerAngles,写成PartData资产。流程上需要注意把Parts和TargetPoints放在同一个父节点层级下,确保localPosition的比较口径一致。
这一步做完,零件"应该装在哪"这个信息就被固化成数据资产了。以后要调整装配工艺、修改顺序,直接改资产,不用动一行代码。
3.3 顺序列表也是数据的一部分
除了位置信息,装配顺序也应该数据化。很多培训项目的工艺是经常变的,这周先装活塞,下周可能要求先装曲轴。如果顺序写死在代码里,改一次要发一次包,客户体验很差。
我把顺序定义成一个可配置的列表,放在AssemblyManager上:
[System.Serializable] public class AssemblyStep { public string partKey; // 零件标识 [TextArea] public string hintText; // 给用户看的提示 } public List<AssemblyStep> assemblyOrder; // 装配顺序 public List<AssemblyStep> disassemblyOrder; // 拆卸顺序两个列表分别控制拆卸和装配的方向。为什么拆装顺序不直接用一个列表反向?因为有些零件拆卸方向和装配方向并不对称,比如某些部位需要先拆支架才能接触螺丝,但装配时支架最后装。两个列表独立配置,灵活性更高。
4. 第二步和第三步,拾取与拖拽:手感决定Demo能不能用
4.1 用射线拾取零件,以及两个容易忽略的细节
拾取操作最常用的方案是Physics.Raycast。在Update里监听鼠标左键按下,从相机发射一条射线,检测命中的Collider,然后拿到它所属的PartController。
private void HandlePickup() { if (Input.GetMouseButtonDown(0)) { if (EventSystem.current.IsPointerOverGameObject()) return; Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(ray, out hit, 100f, interactableLayer)) { PartController part = hit.collider.GetComponentInParent<PartController>(); if (part != null && AssemblyManager.Instance.CanInteract(part.partKey)) { part.StartDrag(); currentPart = part; } } } }这里有三个细节值得单独说明。
第一,LayerMask。建议给所有可拆零件单独设置一个Layer(比如Interactable),射线只检测这个Layer。如果不指定Layer,场景里的地面、墙壁、装饰物都可能挡住射线,用户会感觉"零件怎么都点不中"。
第二,UI遮挡判断。当Canvas是Screen Space Overlay模式时,如果鼠标正好在一个UI按钮上,应该优先让UI响应,而不是拾取零件。EventSystem.current.IsPointerOverGameObject()可以解决这个问题。但要注意移动端上,这个方法的参数需要传具体的手指ID,直接传0在部分设备上会失效,移动端建议单独处理。
第三,GetComponentInParent而不是GetComponent。因为零件可能有两个层级,Collider挂在某个子物体上,PartController挂在父物体上。用GetComponentInParent能直接找到正确的控制脚本,不用人为保证Collider和PartController一定在同一层级上。
4.2 拖拽逻辑:为什么直接"撸"鼠标会乱飞
拾取只是开始,拖拽才是交互的关键。很多初学者会直接把鼠标的屏幕坐标转成世界坐标,然后赋值给零件。这个做法在正交相机下还能看,在透视相机下就会出问题:零件会随着鼠标靠近屏幕边缘产生巨大的偏移,因为透视相机的屏幕边缘,一像素对应的世界距离比中心大得多。
更稳妥的做法是"固定抓取深度":开始拖拽时,记录"零件中心到相机的距离"以及"鼠标射线与零件中心之间的偏移",拖拽过程中始终保持这个深度和偏移关系。
public void StartDrag() { isDragging = true; rb.isKinematic = true; Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); grabDistance = Vector3.Distance(transform.position, Camera.main.transform.position); dragOffset = transform.position - Camera.main.transform.position - ray.direction * grabDistance; } private void DragTo(Vector3 mousePos) { if (!isDragging) return; Ray ray = Camera.main.ScreenPointToRay(mousePos); Vector3 targetPos = Camera.main.transform.position + ray.direction * grabDistance + dragOffset; transform.position = targetPos; }为什么最后要加dragOffset?因为鼠标射线几乎不可能精确落在零件枢轴点上,尤其大零件,鼠标通常点在零件边缘。如果直接把零件中心移到射线位置,零件会跳变一下,手感很生硬。加了offset之后,零件会保持"鼠标点中的相对位置"和"零件中心"的对应关系,移动起来非常自然。
拖拽期间还要注意:grabDistance是拖拽开始时记录的,如果用户拖拽过程中滚动滚轮缩放相机,这个值不会自动变化,零件会突然跑到离相机更近或者更远的地方。我一般会在拖拽期间把相机缩放锁死,逻辑简单,用户也不会觉得奇怪。毕竟拿着零件的时候转视角、缩放本身就是一种危险操作。
4.3 高亮反馈:用MaterialPropertyBlock,而不是改Material
拾取到零件后,必须给用户视觉反馈,否则用户不知道自己的点击是否生效。最常见的效果是高亮——零件整体发光、边缘发亮或者变色。
许多新人会尝试直接改Renderer的material颜色,或者用Instantiate复制一份材质再改。这两种方式都有问题。直接改sharedMaterial会影响场景中所有使用该材质的物体,把发动机所有活塞全变成红色都有可能出现;Instantiate材质则会反复创建材质实例,改完不还原,内存暴涨。
正确方案是MaterialPropertyBlock。它为每个Renderer实例维护一份覆盖参数,不复制材质,可以随时设置随时清除,性能也更好。
public class HighlightManager : MonoBehaviour { public static HighlightManager Instance { get; private set; } [SerializeField] private Color highlightColor = new Color(0.2f, 0.7f, 1f, 1f); private MaterialPropertyBlock block; private Renderer currentRenderer; private void Awake() { Instance = this; block = new MaterialPropertyBlock(); } public void Highlight(Renderer target) { ClearHighlight(); if (target == null) return; block.SetColor("_EmissionColor", highlightColor * 1.5f); target.SetPropertyBlock(block); currentRenderer = target; } public void ClearHighlight() { if (currentRenderer == null) return; currentRenderer.SetPropertyBlock(null); currentRenderer = null; } }这里有一个坑:Unity内置管线材质使用_EmissionColor属性名,URP标准材质则可能需要_BaseColor加Emission组合。如果模型用了第三方Shader,SetPropertyBlock可能没有效果。比较稳妥的做法是先判断材质是否存在对应属性:
if (target.material.HasProperty("_EmissionColor")) { block.SetColor("_EmissionColor", highlightColor * 1.5f); }4.4 旋转零件:机械装配必须有的辅助操作
机械装配里,角度对准是逃不掉的。发动机的活塞有朝向,气门弹簧压盖要转到某个角度才能卡上。
如果只给移动不给旋转,用户就只能在空间里瞎转镜头去碰运气,体验非常糟糕。我一般给两个旋转入口:按住Alt加鼠标左键,绕敏感轴缓慢旋转;或者用一个单独的旋转速度参数控制。代码逻辑如下:
private void ApplyRotation() { if (Input.GetKey(KeyCode.LeftAlt) && Input.GetMouseButton(0)) { float rotX = Input.GetAxis("Mouse X") * rotationSpeed * Time.deltaTime; float rotY = Input.GetAxis("Mouse Y") * rotationSpeed * Time.deltaTime; transform.Rotate(transform.up, rotX, Space.World); transform.Rotate(transform.right, rotY, Space.World); } }旋转空间建议使用World下的自身轴。用Self可能会在零件已经做了非均匀缩放时出现异常旋转,而World下的绕自身up和right轴旋转,用户理解起来也更直观。
旋转速度不要设太大,我一般控制在60到120度每秒。太快了很难精确对准,太慢了用户转半天也转不过来。
5. 第四步,到位判定与吸附:装配感的真正来源
5.1 为什么只用距离判定会翻车
先把结论摆出来:装配是否到位,必须同时判断位置接近程度和姿态接近程度。
只判断距离会出现一个很常见的翻车现象:零件的位置完全正确,但角度是歪的。比如气道法兰差了180度,系统把它吸上去了,视觉上就是一个错位的装配体。只判断角度不判断位置也离谱,零件悬空好几厘米,但角度对,照样吸上去。
正确做法是双阈值判定:
private void TrySnap() { float dist = Vector3.Distance(transform.position, targetPoint.position); float angle = Quaternion.Angle(transform.rotation, targetPoint.rotation); if (dist <= snapDistance && angle <= snapAngleThreshold) { StartCoroutine(SnapRoutine()); } }阈值的大小取决于零件尺寸。以发动机为例,如果装配缝隙只有两三毫米,snapDistance不会太大。但也不建议直接设成"必须精确到2毫米"——用户手稍微抖一下,零件就放不进去,体验很烂。实践中我会把snapDistance放宽到装配缝隙的两倍左右,即使偏差一点,吸附过程也能把零件拉正。
snapAngleThreshold建议30度左右。这个角度足够宽松,让用户不用精确对准就能触发吸附;但又不会宽松到"歪着45度也吸进去"。
5.2 为什么用Quaternion.Angle而不是Vector3.Angle
这里有一个具体的、很容易被忽视的技术细节。
Quaternion.Angle返回两个旋转之间的最小夹角,范围0到180度,是一个直接表示"差多少度"的标量。而常用的Vector3.Angle比较的是两个方向向量之间的夹角,要求你先提取某个轴的方向向量。
我第一次写判定时用了Vector3.Angle(transform.forward, target.forward),结果发现:零件绕自身Z轴旋转90度时,forward向量并不变,于是角度判定一直通过,零件歪着被吸进去了。表面上看"我说的是同一个方向啊",但三维旋转不是只有一个方向向量能描述的。
所以,直接用Quaternion.Angle是最省心也不容易出错的做法。
5.3 吸附动画:让"咔嚓"一声更有说服力
满足阈值条件后,不建议直接瞬移目标点,那样显得很生硬。一个约0.15到0.3秒的平滑插值,会让装配动作带有真实机械扣合的感觉。
我用的协程实现:
private IEnumerator SnapRoutine() { float duration = 0.2f; float elapsed = 0f; Vector3 startPos = transform.position; Quaternion startRot = transform.rotation; while (elapsed < duration) { elapsed += Time.deltaTime; float t = Mathf.SmoothStep(0f, 1f, elapsed / duration); transform.position = Vector3.Lerp(startPos, targetPoint.position, t); transform.rotation = Quaternion.Slerp(startRot, targetPoint.rotation, t); yield return null; } transform.position = targetPoint.position; transform.rotation = targetPoint.rotation; OnSnapCompleted(); }Mathf.SmoothStep会让插值过程"越接近越慢",比Linear更接近真实机械扣合的感觉。吸附完成后,调用OnSnapCompleted通知AssemblyManager推进状态。
另外可以加一个非常短促的音效,或者一个小小的粒子效果。用户对"这一步完成了"的感知,声音和视觉效果比文字提示强得多。
5.4 吸附后的物理状态切换
零件吸附完成后,它的Rigidbody怎么处理?
如果保持isKinematic = false,那么场景中其他动态物体碰撞到它,它会飞走,这显然不对。如果保持isKinematic = true,那么后续拆卸时还要手动改回去,逻辑上多一步。
一个比较优雅的做法是:吸附完成后直接禁用Rigidbody组件。rb.enabled = false。物理引擎会完全忽略这个物体,不会受重力、碰撞影响,性能还更好。拆卸时再把rb.enabled设为true,同时把isKinematic设回true,零件就能继续被拖拽。
但这里有个前提:零件本身不能有持续运动需求。机械拆装场景里,已装配零件本来就应该静止,所以禁用它完全合理。
6. 第五步,流程控制:状态机与步骤提示
6.1 四种状态与状态流转
机械拆装不允许用户像抓娃娃一样随意拿任何一个零件。教学培训场景尤其要求严格顺序:先拆某个盖子,再拆内部零件;装配则严格逆序。
这时候需要一个整体状态机来管理流程。我通常定义四个状态:
public enum AssemblyState { Initial = 0, // 初始,所有零件都在装配位 Disassembling, // 正在拆卸 Assembling, // 正在装配 Complete // 装配完成 }从Initial进入Disassembling,通常由用户拆下第一个零件触发;从Disassembling进入Assembling,发生在所有零件都拆完时;组装完最后一个零件,进入Complete。
为了简化逻辑,实际项目中更常见的做法是:拆卸阶段可以任意拆所有可拆零件(或者按严格的拆卸顺序列表);装配阶段则必须严格按照装配顺序列表一步步走。我这里用一个折中方案:拆卸和装配都严格按顺序来。
6.2 顺序控制的核心逻辑
状态机对外暴露一个核心方法:CanInteract。所有交互入口,拾取、拖拽、吸附,都要先问这个方法是否允许操作当前零件。
public bool CanInteract(string partKey) { if (state == AssemblyState.Complete) return false; if (state == AssemblyState.Disassembling) { return partKey == disassemblyOrder[currentDisassemblyIndex].partKey; } if (state == AssemblyState.Assembling) { return partKey == assemblyOrder[currentAssemblyIndex].partKey; } return false; }这个方法的意图非常清楚:同一时刻只有一个零件可以被操作。其他零件即使被射线命中,也会被直接拒绝。这样用户就不会在装配阶段错误地拿起一个"还没轮到"的零件。
状态切换的时机同样重要。拆卸时,当最后一个可拆零件被拆下,自动进入Assembling状态,并且把装配索引归零。装配时,每完成一个零件,currentAssemblyIndex加一,直到全部完成,进入Complete。
当进入Complete时,建议给出一个明显的整体反馈。比如整个发动机轻微高亮几秒,或者播放一段完成音效,让用户确信"真的是装完了"。
6.3 UI步骤提示与数据驱动文案
只有状态机还不够,用户需要一个明确的"现在该做什么"的指示。最常见的是屏幕底部一个半透明的提示条,显示"第3步:安装活塞,注意方向标记朝前"。
这部分我把文案放在AssemblyStep里配置,而不是写死在代码:
public void NotifyStepChanged() { string stepInfo = ""; if (state == AssemblyState.Disassembling) { stepInfo = "拆卸阶段:" + disassemblyOrder[currentDisassemblyIndex].hintText; } else if (state == AssemblyState.Assembling) { stepInfo = "装配阶段:" + assemblyOrder[currentAssemblyIndex].hintText; } else if (state == AssemblyState.Complete) { stepInfo = "装配完成!"; } UIManager.Instance.UpdateStep(stepInfo); }提示文案不要写在屏幕中央,会挡操作视线。底部居中做一个半透明条幅是最友好的。每个零件吸附完成后,配合前文提到的音效或者闪烁效果,用户对整个流程的进度掌控会很清楚。
UI为什么也要数据驱动?因为培训客户经常会改操作流程和提示文案。把这些内容做成配置,他们甚至可以在交付后自己微调,不需要开发人员介入。这类项目维护成本的大头往往不在代码逻辑,而在文案和工艺的可调整性上。
7. 完整代码整合与配置手册
7.1 脚本清单
把前面所有逻辑整合成一套完整的脚本结构,方便读者直接落地。
| 脚本 | 挂载位置 | 核心职责 |
|---|---|---|
| PartData.cs | 资源资产 | 存储零件标识、目标位姿、提示文案 |
| PartController.cs | 每个可拆零件 | 拾取、拖拽、旋转、吸附判定 |
| AssemblyManager.cs | EngineRoot或空物体 | 状态机、顺序控制、全局事件 |
| HighlightManager.cs | 空物体(单例) | 管理高亮显示与清理 |
| OrbitCamera.cs | 主摄像机 | 右键旋转视角、滚轮缩放 |
| UIManager.cs | Canvas | 更新步骤提示文本 |
7.2 核心代码全文
下面直接把核心脚本完整列出。由于HighlightManager和OrbitCamera代码量不大,也一并给出。
PartController完整代码:
using System.Collections; using UnityEngine; [RequireComponent(typeof(Rigidbody))] public class PartController : MonoBehaviour { public string partKey; public Transform targetPoint; // 目标位姿点 public float snapDistance = 0.05f; // 吸附距离阈值 public float snapAngleThreshold = 30f; // 吸附角度阈值 public float rotationSpeed = 90f; // 旋转速度(度/秒) [HideInInspector] public bool isAssembled = true; // 初始状态为已装配 private Rigidbody rb; private bool isDragging = false; private float grabDistance; private Vector3 dragOffset; private void Awake() { rb = GetComponent<Rigidbody>(); rb.useGravity = false; rb.isKinematic = true; } private void Update() { if (isDragging && Input.GetKey(KeyCode.LeftAlt) && Input.GetMouseButton(0)) { ApplyRotation(); } } public void StartDrag() { if (isAssembled) return; isDragging = true; rb.isKinematic = true; Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); grabDistance = Vector3.Distance(transform.position, Camera.main.transform.position); dragOffset = transform.position - Camera.main.transform.position - ray.direction * grabDistance; } public void DragTo(Vector3 mousePos) { if (!isDragging) return; Ray ray = Camera.main.ScreenPointToRay(mousePos); Vector3 targetPos = Camera.main.transform.position + ray.direction * grabDistance + dragOffset; transform.position = targetPos; } public void EndDrag() { if (!isDragging) return; isDragging = false; TrySnap(); } private void TrySnap() { float dist = Vector3.Distance(transform.position, targetPoint.position); float angle = Quaternion.Angle(transform.rotation, targetPoint.rotation); if (dist <= snapDistance && angle <= snapAngleThreshold) { StartCoroutine(SnapRoutine()); } } private IEnumerator SnapRoutine() { float duration = 0.2f; float elapsed = 0f; Vector3 startPos = transform.position; Quaternion startRot = transform.rotation; while (elapsed < duration) { elapsed += Time.deltaTime; float t = Mathf.SmoothStep(0f, 1f, elapsed / duration); transform.position = Vector3.Lerp(startPos, targetPoint.position, t); transform.rotation = Quaternion.Slerp(startRot, targetPoint.rotation, t); yield return null; } transform.position = targetPoint.position; transform.rotation = targetPoint.rotation; isAssembled = true; rb.enabled = false; AssemblyManager.Instance.OnPartAssembled(partKey); } public void SetDisassembled() { isAssembled = false; rb.enabled = true; rb.isKinematic = true; } private void ApplyRotation() { float rotX = Input.GetAxis("Mouse X") * rotationSpeed * Time.deltaTime; float rotY = Input.GetAxis("Mouse Y") * rotationSpeed * Time.deltaTime; transform.Rotate(transform.up, rotX, Space.World); transform.Rotate(transform.right, rotY, Space.World); } }AssemblyManager完整代码:
using System.Collections.Generic; using UnityEngine; public class AssemblyManager : MonoBehaviour { public static AssemblyManager Instance { get; private set; } [System.Serializable] public class AssemblyStep { public string partKey; [TextArea] public string hintText; } public List<PartController> parts; // 场景中的所有可拆零件 public List<AssemblyStep> disassemblyOrder; // 拆卸顺序 public List<AssemblyStep> assemblyOrder; // 装配顺序 public enum AssemblyState { Initial, Disassembling, Assembling, Complete } public AssemblyState State { get; private set; } private int disassemblyIndex; private int assemblyIndex; private void Awake() { Instance = this; State = AssemblyState.Initial; } private void Start() { // 收集所有可拆零件 parts = new List<PartController>(GetComponentsInChildren<PartController>()); // 初始化目标点引用(partKey匹配) foreach (var part in parts) { part.targetPoint = transform.Find("TargetPoints/" + part.partKey + "_Target"); } } public bool CanInteract(string partKey) { if (State == AssemblyState.Complete) return false; if (State == AssemblyState.Initial || State == AssemblyState.Disassembling) { return partKey == disassemblyOrder[disassemblyIndex].partKey; } if (State == AssemblyState.Assembling) { return partKey == assemblyOrder[assemblyIndex].partKey; } return false; } public void OnPartDisassembled(string partKey) { if (disassemblyIndex == disassemblyOrder.Count - 1) { // 全部拆完,切换到装配阶段 State = AssemblyState.Assembling; assemblyIndex = 0; } else { disassemblyIndex++; } NotifyStepChanged(); } public void OnPartAssembled(string partKey) { if (assemblyIndex == assemblyOrder.Count - 1) { State = AssemblyState.Complete; } else { assemblyIndex++; } NotifyStepChanged(); } private void NotifyStepChanged() { string stepInfo = ""; if (State == AssemblyState.Disassembling) { stepInfo = "拆卸:" + disassemblyOrder[disassemblyIndex].hintText; } else if (State == AssemblyState.Assembling) { stepInfo = "装配:" + assemblyOrder[assemblyIndex].hintText; } else if (State == AssemblyState.Complete) { stepInfo = "全部完成"; } if (UIManager.Instance != null) { UIManager.Instance.UpdateStep(stepInfo); } HighlightManager.Instance.ClearHighlight(); } }OrbitCamera完整代码:
using UnityEngine; public class OrbitCamera : MonoBehaviour { public Transform target; // 观察目标 public float distance = 6f; public float rotationSpeed = 200f; public float zoomSpeed = 2f; public float minDistance = 1f; public float maxDistance = 20f; private float yaw; private float pitch = 30f; private void LateUpdate() { // 左键拖拽旋转视角 if (Input.GetMouseButton(0) && !Input.GetKey(KeyCode.LeftAlt)) { yaw += Input.GetAxis("Mouse X") * rotationSpeed * Time.deltaTime; pitch -= Input.GetAxis("Mouse Y") * rotationSpeed * Time.deltaTime; pitch = Mathf.Clamp(pitch, -90f, 90f); } // 滚轮缩放 float scroll = Input.GetAxis("Mouse ScrollWheel"); distance -= scroll * zoomSpeed; distance = Mathf.Clamp(distance, minDistance, maxDistance); Quaternion rotation = Quaternion.Euler(pitch, yaw, 0f); Vector3 pos = target.position - rotation * Vector3.forward * distance; transform.position = pos; transform.rotation = rotation; } }UIManager示意代码:
using TMPro; using UnityEngine; public class UIManager : MonoBehaviour { public static UIManager Instance { get; private set; } [SerializeField] private TextMeshProUGUI stepText; private void Awake() { Instance = this; } public void UpdateStep(string info) { if (stepText != null) { stepText.text = info; } } }7.3 场景搭建分步指南
光有代码还不够,场景配置步骤也必须清晰。建议按下面顺序操作:
- 新建Unity 2021.3或2022.3 LTS工程,使用内置渲染管线或URP均可。
- 导入发动机FBX模型。FBX模型的导入参数中,建议关闭Read/Write,开启Mesh Compression(如果不需要运行时改网格),Scale Factor保持默认1。
- 按照第2章的结构创建EngineRoot、Parts、TargetPoints三个空物体,把模型零件拖到Parts下。
- 给机体之外的所有可拆零件挂上BoxCollider(可以加多个BoxCollider模拟复杂外形),再挂Rigidbody,最后挂PartController。
- 在TargetPoints下创建与每个零件对应的空目标点,并把目标点摆到正确的装配位置和角度。
- 创建PartData资产并配置(或者使用编辑器采集工具自动写入)。
- 在场景中创建空物体挂AssemblyManager,关联所有PartController与顺序列表配置。
- 创建Canvas,挂UIManager,底部放TextMeshPro文本。
- 给主摄像机挂OrbitCamera,把target指向EngineRoot。
- 运行测试,按住Alt加鼠标左键旋转零件,把零件拖到目标点附近,验证吸附逻辑与状态流转。
我强烈建议先用2到3个零件跑通整个流程,再逐步补充其余零件。一次性配几十个零件,出问题时会非常难排查,因为交互逻辑、物理配置、数据配置三者可能同时出错。
8. 实战避坑:坐标、碰撞体、材质与多平台发布
8.1 局部坐标与目标点比较口径的坑
这是我在多个项目里反复踩过的一个坑,单独拿出来说。
PartData里存的是相对EngineRoot的局部坐标。如果零件在场景里的父节点不是EngineRoot,而是被临时挂到了其他地方(比如拖拽过程中临时改父节点),那么localPosition的计算基准就变了,吸附判定全乱。
我的做法是:吸附判定过程中,全部使用世界坐标,也就是直接用targetPoint.position与transform.position比较,不要心血来潮去读localPosition。目标点的世界位姿是场景里固定摆放的,零件移动到任何位置,世界坐标的比较始终准确。
如果你在配置工具里采集的是零件的localPosition,写入PartData时可以做一个换算:
Vector3 worldPos = part.transform.position; Vector3 localPos = engineRoot.InverseTransformPoint(worldPos);这样保证数据存取和运行时判定各自使用正确的坐标空间,两者不混淆。
8.2 网格碰撞体的性能与抖动
发动机模型如果直接用MeshCollider,可能出现三类问题。
第一类,性能浪费。高精度网格碰撞体的构建和物理计算很费CPU,移动端和WebGL平台尤其明显。第二类,拖拽抖动。MeshCollider与Rigidbody交互时,如果网格不是凸包,没法直接用于碰撞;勾选Convex后,碰撞体形状与实际视觉网格不一致,射线命中的位置和"鼠标点到的位置"出现偏移,用户会觉得点不准。第三类,稳定性问题。在非凸网格碰撞体上拖动物体,偶尔会发生穿透或者奇怪的弹跳。
所以我在所有可拆零件上都用BoxCollider或CapsuleCollider。发动机缸体用一个大BoxCollider,气门室盖用一个小BoxCollider放在对应位置,以此类推。零件的物理轮廓不需要完全精确,够用就行。毕竟拆装教学的诉求是"点一下活塞能选中、活塞能准确装进洞",不是模拟真实力学。
8.3 高亮恢复的时机
MaterialPropertyBlock解决了材质污染的问题,但高亮"什么时候恢复"依然有讲究。
一定要集中管理高亮的清除时机。我写的HighlightManager在每次设置新高亮前都会调用ClearHighlight,并且在零件吸附完成、流程状态切换时也会主动清理。这样就不会出现"上上个零件还在发光"的尴尬。
还有一个细节:如果你给零件的多个Renderer都设置过PropertyBlock,清除时要遍历清除。最简单的方式是HighlighManager记录当前被高亮的所有Renderer列表,Clear时遍历SetPropertyBlock(null)。只记录第一个Renderer是常见疏漏,会导致部分子物体一直处于高亮状态。
8.4 发布到WebGL和微信小游戏时要注意什么
拆装Demo在编辑器和PC上跑通,不代表所有平台都稳。我经历过不少项目发布到别的平台后一堆小问题的情况。
第一,物理精度差异。WebGL平台的物理引擎行为和编辑器有细微差别,偶发穿透时有发生。可以把Fixed Timestep从默认的0.02改成0.015,或者稍微放大碰撞体尺寸,都能缓解。
第二,Shader兼容。带发光效果的Shader在移动端和WebGL低端设备上性能不好。如果用了Standard材质的Emission,发布时记得关闭HDR和Bloom,或者改用URP里的SimpleLit,效果稳定很多。
第三,内存限制。微信小游戏和WASM的堆内存有限,如果发动机模型面数特别高,建议导入时压缩Mesh顶点精度,纹理压缩为ASTC或ETC2,贴图尺寸控制在1024以内。否则启动白屏或者运行中闪退会很频繁。
第四,代码兼容。Editor相关代码,比如自定义Inspector、Gizmos绘制,必须放到Editor文件夹下,否则打包安卓或WebGL时编译报错。运行时脚本要避免用任何UnityEditor命名空间。
9. 从演示到落地:留一些扩展空间
这一小节算是我做这类项目的一些体会。
交互式装配这套框架,Essence是"数据+状态机+阈值判定"三层结构。发动机只是其中一个载体。换一个变速器模型、压缩机模型,或者一套家具组装Demo,逻辑基本不用动,只需要换模型、重新生成PartData、调整阈值参数。
有一个比较容易被忽视的扩展方向是兼容外设。比如VR手柄的抓取、力反馈手套、或者工业培训里常用的"扫描枪识别螺栓"这类流程。当前代码里PartController的StartDrag和DragTo,其实已经把手势和交互点抽象出来了。后续要接VR的话,把鼠标输入替换成XR Grab逻辑就行,状态机和数据层完全不用改。
另外,如果项目用于正式培训,我建议把吸附阈值做成运行时可以调整的选项。不同学员的熟练度差别很大,有人在操作时手很稳,有人就是放不准。设置一个"新手宽松模式"和"高手严格模式",会让产品显得更加人性化。而这一切只需要在设置界面里调两个浮点参数,骨架代码完全不用动。