简介:这是一份面向Unity初中级开发者与3D可视化爱好者的完整项目资源,围绕“3D模型展示”场景,系统覆盖3D标注、环绕相机、步骤列表与拆装动画等核心交互功能,适用于产品展示、教学演示及AR/VR应用开发。压缩包共4300个文件,包含735个C#控制脚本、280张PNG贴图、138个Markdown说明文档,并有材质、FBX模型、Prefab预制体、Shader、UnityPackage等辅助资源;其中C#脚本负责交互逻辑,贴图与材质提供渲染表现,文档可指导快速上手,整体体积65.71MB,目录结构清晰,方便按功能模块检索。已有3745人学习使用。通过该资源,开发者可以掌握Unity中3D模型导入与优化技巧,了解如何搭建可交互的标注系统、实现360度环绕观察视角,并借助Animator状态机与物理引擎设计拆装动画与步骤引导,最终构建出具有高度互动性和教育价值的3D应用,有效提升产品展示或教学项目的体验。
1. 把模型展示、3D标注、拆装动画放进同一个Unity工程,值不值
一个做机械臂毕业设计的朋友上周问我:客户想看零件拆装过程,模型已经用SolidWorks画好,能不能直接在Unity里做3D模型展示,顺带点一下零件就出标注,还能按顺序播放拆装动画?我说能,而且这三件事放同一个Unity工程里做,比拆成三个软件分别演示划算得多。Unity 3D模型展示解决“看得见”,3D标注解决“看得懂”,拆装动画解决“看得透”,一套工程既能当销售演示,又能当售后培训素材,还能导出WebGL扔到网页上给客户自己点。适合谁?适合做工业设备展示、产品说明书电子化、机械教学课件的人,以及被客户临时加需求的技术支持。本文按我实际做过的方案讲:模型怎么导、标注怎么点、拆装怎么写,最后给你一份能照着改的脚本。
2. 从模型导入到轨道相机:先把3D展示的地基打牢
2.1 模型格式与导入管线:为什么首选FBX而不是直接拖原始格式
做Unity 3D模型展示,第一个坑往往不是代码,而是模型进不了Unity。客户交付的模型常见格式是STP、IGES、STEP,这是CAD软件的通用交换格式,Unity不认。另一类常见格式是OBJ,Unity虽然直接支持,但OBJ不带骨骼、不带层级、材质也经常丢。我的习惯是:所有原始模型先让建模同事在3ds Max、Blender或SolidWorks自带的转换器里统一转成FBX,勾选“嵌入媒体”选项,让贴图跟着模型走。
FBX之所以是首选,是因为它同时保留模型层级、材质引用、动画轨道和轴信息。对于拆装动画来说,模型层级尤其关键——如果零件之间的父子关系在FBX里是完整的,后面的拆装脚本可以直接按层级遍历子物体,少写一半代码。glTF格式在Web端是王者,配合model-viewer做纯网页3D展示很轻量,但我做Unity项目还是优先FBX,因为Unity对FBX的原生管线最成熟,导入设置里的选项最全。
导入时有一个必调参数:缩放单位。SolidWorks和3ds Max的默认单位不同,导出FBX后进Unity经常出现模型巨大或微小到看不见。选中导入的模型,在Inspector面板的Scale Factor里核对,我一般会把“Use File Scale”关掉,手动把它调成1,然后看场景里模型的实际尺寸。还有一个常见问题是轴方向。CAD软件习惯Y轴向上,Unity也是Y轴向上,一般不用改;但某些机械设计软件导出的模型是Z轴向上,转过来后模型会躺倒,这时在FBX导入设置的Scene一栏里旋转90度修正即可。
2.2 相机与光照:让展示效果不翻车的三个最少配置
模型立住之后,展示效果取决于相机和光照。我见过不少工程,模型挺好,但场景里只有一个平行光,金属零件看上去是黑的,或者转起来时明暗变化剧烈。这里给出我固定的三个最少配置。
第一,颜色空间用Linear。在Player Settings里把Color Space从Gamma切到Linear,金属质感的模型在Linear下反射分布更真实,这是性价比最高的一次设置。第二,光照方案用“一主一辅一环境”:一个平行光做主光,方向略向下倾斜;一个强度很低的点光源或半球光做补光,防止阴影面死黑;环境光用Skybox默认即可,但要把Environment Lighting的Intensity调到0.5到0.8之间,避免整体过曝。第三,相机抗锯齿开FXAA。做模型展示时用户会高频旋转视角,MSAA在这种场景下对性能开销偏高,FXAA实现简单且不会让边缘闪烁。
相机控制方面,用户最熟悉的手势是“拖拽旋转、滚轮缩放”,这也是热搜里“Unity摄像机跟随”最常见的诉求。这里给一个最小可用的轨道相机脚本,挂在主相机上,把模型的中心空物体拖到Target字段里即可。
using UnityEngine; public class OrbitCamera : MonoBehaviour { public Transform target; // 旋转围绕的目标点,通常是模型的中心空物体 public float rotateSpeed = 5f; // 拖拽旋转速度 public float zoomSpeed = 2f; // 滚轮缩放速度 public float minDistance = 0.5f; public float maxDistance = 20f; private float currentX = 0f; private float currentY = 20f; // 初始俯视角 private float currentDistance = 5f; void Update() { // 鼠标左键拖拽旋转视角 if (Input.GetMouseButton(0)) { currentX += Input.GetAxis("Mouse X") * rotateSpeed; currentY -= Input.GetAxis("Mouse Y") * rotateSpeed; currentY = Mathf.Clamp(currentY, -90f, 90f); // 限制俯仰角,避免视角穿到地下 } // 滚轮控制距离 float scroll = Input.GetAxis("Mouse ScrollWheel"); currentDistance -= scroll * zoomSpeed; currentDistance = Mathf.Clamp(currentDistance, minDistance, maxDistance); } void LateUpdate() { if (target == null) return; // 用四元数计算旋转后的方向,再按距离偏移得到相机位置 Quaternion rotation = Quaternion.Euler(currentY, currentX, 0f); Vector3 direction = rotation * Vector3.back; transform.position = target.position + direction * currentDistance; transform.LookAt(target.position); } }这段脚本的逻辑是:用两个角度变量currentX和currentY累计鼠标输入,然后用四元数乘Vector3.back得到相机在目标后方的方向向量,乘上距离后就是相机位置,最后LookAt保证始终看向模型中心。参数说明:rotateSpeed建议设在3到6之间,太快了用户容易头晕,太慢了显得木;currentY初始值设20到30度,让模型呈现轻微俯视的“展示视角”,比正平视更有立体感。需要强调一句:这段脚本只处理了旋转和缩放,没有处理平移,工业模型经常需要平移查看细节,你可以在Update里加一行:在鼠标中键拖拽时,让target的位置反向移动即可。
提示:如果你的模型非常大,比如几十米长的设备,Camera的Far Clip Plane要调大,否则转着转着模型后半部分被裁剪掉了,看起来像模型被“切了一刀”。Far值建议设为模型最大尺寸的10倍以上。
3. 用射线拾取做3D标注:一套能落到工程里的标注交互
3.1 从屏幕点击到模型表面:射线拾取与坐标换算
3D标注需求拆开看,就是“点击模型上的某个位置,在那里挂一个标签,标签指向这个位置,并说明这是什么零件”。这和“3D点云标注”不同,点云标注是把点云数据里的离散点归类和打框,而这里是网格模型上的表面点拾取。两者的共同点是都要做空间坐标的捕捉,区别在于点云没有连续表面,射线打到的点要靠最近邻搜索;网格模型直接用Physics.Raycast就能命中表面三角面。
射线拾取的核心代码就是把屏幕坐标转成一条从相机出发的射线,然后检测它撞到哪个Collider。这里有一个前置条件:模型上的每个零件都必须有Collider。很多从CAD导出的FBX模型在Unity里是空有MeshRenderer没有Collider,射线打上去直接穿透。我一般建议在模型的根节点下加一个“自动添加Collider”的编辑器脚本,统一给所有带MeshRenderer的子物体补一个MeshCollider。注意MeshCollider性能开销较大,零件数量超过几百个时建议改用BoxCollider或Convex类型。
using UnityEngine; public class AnnotationClicker : MonoBehaviour { public Camera mainCamera; public GameObject annotationPrefab; // 标注预制体,建议是一个世界空间Canvas + 文本 + 指线 void Update() { if (Input.GetMouseButtonDown(0)) { // 从屏幕点击位置生成射线 Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition); RaycastHit hit; // 射线命中模型表面 if (Physics.Raycast(ray, out hit, Mathf.Infinity)) { // 把命中点从世界坐标转到模型本地坐标,保存时才有意义 Vector3 localPoint = hit.collider.transform.InverseTransformPoint(hit.point); CreateAnnotation(hit.collider.gameObject, hit.point, localPoint); } } } void CreateAnnotation(GameObject target, Vector3 worldPoint, Vector3 localPoint) { // 在命中点生成一个标记点,并记录它挂在哪个零件下 GameObject mark = Instantiate(annotationPrefab, worldPoint, Quaternion.identity, target.transform); AnnotationData data = mark.GetComponent<AnnotationData>(); data.partName = target.name; data.localPosition = localPoint; mark.name = "Annotation_" + target.name; } }这段脚本做了两件重要的事:一是用ScreenPointToRay把鼠标位置变成世界空间的射线;二是把命中点的世界坐标转成目标物体的本地坐标并保存。为什么必须保存本地坐标?因为模型如果有拆装动画,零件会移动、旋转、甚至飞出画面,如果标注点只记录世界坐标,零件一动标注就跟丢了。挂在零件Transform下,零件移动时标注自动跟随,这是做3D标注的基本素养。
参数说明:Physics.Raycast的第四个参数没写LayerMask,意味着所有带Collider的物体都会被射线检测到,包括地面、UI面板、相机周围的辅助碰撞体。实际项目里建议给模型专门设一个Layer,比如叫“Model”,然后在Raycast时传LayerMask,避免点击时误触其他对象。另外annotatedPrefab要提前做好,我下面会讲预制体的结构。
3.2 标注预制体设计:世界空间Canvas、指线、文本三者如何组合
标注物的设计决定了最终视觉效果。我测试过三种方案:第一种是用TextMesh,优点是轻量、随场景缩放,缺点是不能被UI系统统一管理,分层控制不方便;第二种是屏幕空间Canvas加射线检测,标注永远面向屏幕,但做不到指向真实模型表面;第三种是我最后采用的方案,也是工业展示里最常用的做法:世界空间Canvas + 指线 + 标签底板。
具体预制体结构是:根节点挂一个世界空间Canvas,Canvas下放一个RawImage作为标签底板,再放一个TextMeshPro作为说明文字,根节点上还挂一个LineRenderer用来画从标签到模型表面的引线。这里的核心技巧是让标签面向相机。世界空间Canvas默认垂直于Z轴,如果相机旋转,标签就会侧着甚至背面朝外。做法是在标注物脚本的Update里每帧执行transform.LookAt(相机),但要注意让标签的正面朝相机而不是背面朝相机,所以LookAt后还要绕本地Y轴旋转180度。
指线的画法是把LineRenderer的PositionCount设为2,第一个点是标签底部锚点的位置,第二个点是标注点位置。这样标签悬浮在标明点上方一点点的位置,引线从标签底部连到零件表面,视觉上像一个“针脚”,指向性很强。为了美观,可以让引线在靠近标注点的那端稍微缩短一点点,防止LineRenderer被模型表面遮挡导致线头插进模型里。
注意:标注文字一旦多起来,性能瓶颈会出现在TextMeshPro的批量重建上。一个标注物对应一个Canvas,每帧都会有rebuild开销。如果界面同时显示超过20个标注,建议考虑把Canvas数量减少,用一个大Canvas挂多个子节点。
4. 拆装动画的两条实现路线:动画烘焙与代码驱动怎么选
4.1 方案A:Animator + 子物体位移,适合固定动作序列
拆装动画从技术实现上分两个阵营。第一个阵营是“在Unity里直接制作动画”——选中一个零件,点Animation窗口的录制键,把零件从装配位置移动到拆除位置,记录关键帧,就得到一段位移动画。这种方法适合拆装路径完全固定、不需要用户干预的场景,比如自动播放的演示视频。
但用Animator做拆装动画有一个隐藏问题:零件移动的路径是不自然的。如果只在位置A和位置B打两个关键帧,零件走直线,当A到B的直线穿过其他零件时就会穿模。解决方法是添加中间关键帧,让零件先向外拔出、再向上移动、最后放到目标位置,路径要人工设计成“绕行”。我一般会在拆卸路径上打三到四个关键帧:初始位置、位移方向的起始段、转折点、终点。另外,拆装顺序在状态机里设定非常不直观,一个20个零件的拆装序列,光状态机的箭头就让人头大。
所以Animator方案适合什么?适合展示相对简单的产品,比如一个鼠标、一个水泵,零件数量在20个以内,拆装动作是演示型的,不需要用户参与。如果客户只要求“播放拆装动画,我坐在旁边看”,用Animator录好后释放双手,效率最高。注意在Recorder模式下录动画时,建议把动画采样率设为30帧/秒,运动曲线会平滑一些;零件的缩放不要录进关键帧,除非你想拍“零件变小再消失”的花活。
4.2 方案B:代码驱动Transform:用协程按轴逐件移出
第二个阵营是“写代码控制Transform”。它的核心思想是:每个零件事先在Inspector里配好一个拆装路径,拆装时让Transform按路径移动。这种方式的好处是灵活,可以让用户点击任意零件单独拆,也可以按序播放,还能中途暂停、回退,甚至把零件高亮一下再拆。
我为机械臂案例写过的拆装方案是这样的:在某个零件上挂一个DisassemblyPart组件,组件里有几个关键字段:OutDirection(拆除方向)、MoveDistance(移动距离)、Delay(本零件开始移动的延时)。组件启动后,用协程把零件从当前位置沿指定方向平移到目标位置,再旋转一个角度,模拟“拔出来再放一边”的效果。
using UnityEngine; using System.Collections; public class DisassemblyPart : MonoBehaviour { public Vector3 outDirection = Vector3.up; // 拆出方向,按需在Inspector里改 public float moveDistance = 0.5f; // 拆出距离 public float duration = 1.2f; // 移动动画时长 public float startDelay = 0f; // 该零件开始拆装的延迟 public bool dismantled = false; public IEnumerator Disassemble() { // 如果已经拆过,避免重复执行 if (dismantled) yield break; dismantled = true; Vector3 startPos = transform.localPosition; Vector3 endPos = startPos + transform.rotation * outDirection * moveDistance; float elapsed = 0f; // 延迟指定时间后再开始移动 while (elapsed < startDelay) { elapsed += Time.deltaTime; yield return null; } // 用插值完成移动,时长由duration控制 elapsed = 0f; while (elapsed < duration) { float t = Mathf.SmoothStep(0f, 1f, elapsed / duration); transform.localPosition = Vector3.Lerp(startPos, endPos, t); elapsed += Time.deltaTime; yield return null; } transform.localPosition = endPos; } }关键点有三个。第一,移动的是localPosition而不是position。因为零件通常是其他父物体的子物体,用本地坐标移动能保证方向始终相对于父物体,不管父物体在场景里怎么旋转,拆装方向都不会乱。第二,outDirection要乘以transform.rotation,否则当零件本身有旋转角度时,Inspector里指定的up实际并不是世界坐标的up。把方向预先应用零件自身的旋转,是避免拆飞方向跑偏的血泪经验。第三,用SmoothStep而不是直接用Lerp的原始t值,这样零件的启动和停止都有轻微缓动,看起来像是有人在用手拆,而不是被机关弹出去。
调用时机:如果想让所有零件按顺序依次拆出,不能同时启动所有协程,那样会一片全飞。做法是把所有零件统一交给一个拆装控制器,控制器遍历List并按StartDelay排序,逐个协程等待。
5. 避坑与排查:标注、拆装、渲染里最常见的5个翻车点
5.1 场景:标注文字被模型遮挡或显示在最前面
现象:点击模型零件生成标注后,标注文字有时被模型挡住,有时又隔着模型显示在最前面,看起来像浮在屏幕上。
原因:世界空间Canvas的渲染顺序和模型叠加时,ZTest判定出了问题。世界空间Canvas本质上是一个放在场景里的Quad,默认的Canvas渲染mode会同模型做深度测试,但Canvas上的UI元素自身没有正确逐字做深度测试,会出现文字被模型盖住或者穿透模型的情况。
解决:与其纠结Canvas的深度测试,不如直接把Canvas上的Canvas组件RenderMode设为World Space后,在CanvasGroup组件里勾选“Ignore Parent Groups”之外的优化项,更稳妥的做法是把标注预制体的Shader换成带ZTest的UI Shader,或者在生成标注时把Canvas的缩放放到极小再拉大来强制刷新深度。我通常的做法是在标注组件的Start里调用一次transform.SetAsLastSibling(),并在Canvas的sortingOrder上给标注层单独设一个值,比如100,让标注层永远显示在最上面。这个方案能解决90%的标注遮挡问题。
5.2 场景:拆装动画播放时零件互相穿模
现象:按顺序拆零件,结果零件A经过零件B所在位置时直接穿过B,视觉上极其尴尬。
原因:拆装路径只考虑了起点和终点,没有考虑路径中段会不会与其他零件相交。另一个原因是拆装顺序不对——如果A被B卡住,正常情况下应该先拆B再拆A,但动画序列写成了先拆A。
解决:两层解法。第一,检查拆装顺序:每个零件拆出前判断它是否被别的零件“压住”,被压住的零件必须先拆。这个检查可以在编辑器里用一个辅助脚本批量检测零件包围盒的重叠关系。第二,调整路径:把outDirection从直线拆出改为“先沿一个初始方向移动一小段距离,再转向移动”。在DisassemblyPart里增加一个两段移动的参数:先垂直拔出一段,再水平移出。比如螺丝,先沿Z轴拧出0.1米,再向上移出0.5米,全程不与其他零件相交。穿模不是玄学,本质上是几何路径设计问题。
5.3 场景:导入的模型全黑或全灰,金属表面没有反光
现象:模型放进场景后一片漆黑,或者灰蒙蒙的像石膏,怎么调灯光都没用。
原因:CAD导出的FBX模型自带材质大多是Standard Shader,但法线方向错误导致光照计算全完——模型三角面的法线指向内部,光照从外面打过来全部被背光面吸收。另一个原因是双面材质问题,模型表面法线朝向混乱时,有些面正常、有些面是黑的。
解决:在导入设置里勾选“Mark Meshes as Convex”之外,检查法线——选中模型,在导入模型的Model页签下勾选“Swap Tangents”或“Generate Lightmap UVs”往往无法修复法线问题。正确做法是在3ds Max或Blender里做一次“统一法线方向”,把模型所有面的法线翻到外面。如果实在没有建模软件,可以在Unity里给材质换成双面Shader。自定义Shader的Cull Mode设为Off,就可以让渲染器忽略背面剔除,模型就不再黑面了。但要注意,双面Shader会让渲染量翻倍,只建议临时救急用。
5.4 场景:点击UI按钮时,背后的模型也被拾取,标注乱飞
现象:界面上点“自动拆装”按钮,结果模型上那个位置也生成了一个标注。
原因:射线拾取没有做UI过滤。事件系统接收到鼠标点击后,射线依然穿透UI面板打到了后面的模型上。
解决:在射线拾取前先判断是否点到了UI。通用做法是用EventSystem判断:
using UnityEngine; using UnityEngine.EventSystems; public class AnnotationClicker : MonoBehaviour { // 被UI遮挡时禁止拾取模型 private bool IsPointerOverUI() { return EventSystem.current != null && EventSystem.current.IsPointerOverGameObject(); } void Update() { if (Input.GetMouseButtonDown(0) && !IsPointerOverUI()) { // 执行拾取逻辑 } } }这个判断虽然简单,但要注意一个边界:如果UI上的Image组件关闭了Raycast Target,IsPointerOverGameObject就识别不到。所以UI组件上有可点击区域就必须保持Raycast Target勾选,否则会把点击事件漏给模型。还有NGUI或旧版Unity UI项目里,要用EventSystem.current.IsPointerOverGameObject(Input.touchCount > 0 ? 0 : -1)来处理触摸事件。
5.5 场景:打包到安卓后标注文字发虚、看不清
现象:编辑器中标注文字锐利清晰,打包到手机后模糊成一片。
原因:TextMeshPro默认字体在低DPI下没有做好动态字体处理,或者根Canvas的缩放模式不对。
解决:给标注Canvas的RectTransform设置正确尺寸,并在Canvas Scaler里把Dynamic Pixels Per Unit设为10以上,具体值可以根据测试调整。TextMeshPro字体资源要开启“Auto Size”或者给一个下限值,防止它在设备上被缩放过小。另外,Texture的Generate Mip Maps要在字体纹理上开启,否则文字远离相机时会出现闪烁或发虚。这个问题在三星和华为设备上出现频率明显偏高,跟GPU的纹理过滤方式有关,提前在模拟器里用多个分辨率测试比事后返工省心。
6. 把演示做成产品:标注数据序列化与自动拆装回放
6.1 用ScriptableObject保存标注与拆装顺序:换模型不必改代码
放任标注脚本裸奔的工程,过两天你会发现“点击生成标注”的玩法在客户现场翻车了,因为临时生成的标注位置全部存在场景里,换一台电脑场景被覆盖就全没了。正确做法是把标注数据和拆装顺序序列化成资源文件,让策划或建模同事能直接在Inspector里配置。做法是定义一个ScriptableObject,里面保存一个标注列表,每个标注项包含零件名、本地坐标、说明文字、是否默认显示。保存成Asset文件后,不管谁来改配置,都不用动代码。
6.2 自动拆装回放:用协程定时播放,标注联动显示
结合前几节的DisassemblyPart,做一个拆装控制器,控制整个流程按顺序执行。控制器遍历所有零件,按StartDelay排序,逐个调用协程并等待完成。自动拆装演示时,每拆出一个零件,对应标注就在界面上浮出——把展示、标注、拆装三件事串成一个闭环。这里用协程作为调度器,天然支持中途暂停、逐帧查看,比Animator方案更可控。
using UnityEngine; using System.Collections; using System.Collections.Generic; public class DisassemblyController : MonoBehaviour { public List<DisassemblyPart> parts; // 在Inspector里按拆装顺序拖入 public float stepPause = 0.5f; // 每拆完一个零件后的停留时间 public bool isPlaying { get; private set; } public IEnumerator PlaySequence() { if (isPlaying) yield break; isPlaying = true; foreach (DisassemblyPart part in parts) { yield return StartCoroutine(part.Disassemble()); yield return new WaitForSeconds(stepPause); } isPlaying = false; } }这段控制器的逻辑是按List顺序逐个拆装。每个零件通过自己的Disassemble方法完成移动后再进入下一个。stepPause控制节奏,0.5秒比较适中,客户演示时建议调到1秒,留出讲解时间。如果你需要“全部同时动作”的效果,比如多个零件同时拆出,就把foreach里的yield return StartCoroutine改成直接StartCoroutine,但要注意拆装过程中零件互相穿模的问题会更难控制。我的建议是给客户的演示版本统一用顺序播放,工程内部调试可以用并行模式快速检查路径。
6.3 交付前检查清单:对照着跑一遍
走过几轮项目后,我形成了自己的验收清单,建议提测前自己先过一遍:
| 检查项 | 标准 | 有问题时看哪一节 |
|---|---|---|
| 模型导入缩放与朝向 | 模型朝正前方,尺寸与真实设备比例一致 | 2.1 |
| 轨道相机操作手感 | 拖拽旋转无漂移,缩放不过近不过远 | 2.2 |
| 标注位置准确性 | 点击模型表面后标注物精确落在点击点附近 | 3.1 |
| 拆装路径不穿模 | 慢速播放时零件之间无视觉穿插 | 4.2、5.1 |
| UI点击与模型拾取互不干扰 | 按钮点击不会触发模型标注 | 5.3 |
| 移动端字体清晰度 | 安卓低分辨率机不糊不闪 | 5.5 |
| 标注数据序列化 | 重启工程后标注位置与文字不丢失 | 6.1 |
另外一个强烈建议:交付时在Editor里把工程内所有场景备份一份,同时把“自动生成标注”的入口做成一个可开关的Debug按钮,免得客户误点把展示界面弄乱。演示版本默认关闭自动拾取功能,只保留自动拆装回放和预设标注,现场效果更稳。做成产品不是功能越多越好,而是该藏的藏好、该显的显出来。这是我的习惯,也是踩了多次坑之后最想让你带走的经验。希望帮到你。
本文还有配套的精品资源,点击获取