简介:这份资源面向Unity开发者、教学课件制作者与产品展示设计人员,聚焦3D模型展示中的交互呈现问题,涵盖模型导入优化、3D标注、环绕相机、步骤列表与拆装动画等核心知识点,适合具备一定Unity基础、希望提升展示类应用互动性的中阶学习者。压缩包共4300个文件,约65.71MB,以cs脚本、meta配置、png贴图、mat材质、prefab预制体、unity场景、shader着色器及fbx模型为主,另含md说明、json数据与dll插件,覆盖从资源导入到动画控制的完整工程结构。目前已有3750人学习下载。读者可从中获得可运行的Unity工程示例,理解如何用Animator状态机与关键帧实现平滑拆装过渡,借助脚本控制Camera围绕目标旋转实现360度查看,并通过UI标签与粒子效果完成随模型移动的注解,同时掌握Rigidbody物理模拟增强真实感的思路,为教育演示、产品拆解与虚拟仿真项目提供可直接参考的实现方案。
1. 从一次“模型转不动”的评审说起
做过 Unity 3D 模型展示的人大概都遇到过这种场面:模型导进来挺好看,鼠标一拖就穿模,标注文字永远背对镜头,拆装动画播到一半零件飞了。更尴尬的是评审现场有人问“这个螺栓能不能单独拆下来看看”,你只能假装没听见。Unity 3D 模型展示、3D 标注、拆装动画这三件事,单拎出来都不算难,难的是它们要跑在同一套场景里,还要让非技术用户点两下就会用。这套方案适合做工业设备演示、教学课件、产品结构说明的开发者,也适合手里已经有 FBX 模型、想快速搭一个可交互展示 Demo 的人。下面按“模型怎么进来、标注怎么挂上去、拆装怎么动起来”的顺序,把我在实际项目里踩过的路讲清楚。
2. 模型导入与场景搭建:从 FBX 到可交互展示
2.1 模型准备阶段的三个硬性检查
在 Unity 里做展示,模型本身的质量决定了后面一半的坑。我一般拿到 FBX 之后先做三件事:检查单位比例、检查 pivot 位置、检查材质球数量。单位比例不对,模型要么像蚂蚁要么像哥斯拉;pivot 不在几何中心,旋转和拆装动画的轴心就会偏;材质球太多,移动端直接爆 draw call。
常见做法是在建模软件里就把单位设成米,导出时勾选“嵌入媒体”,Unity 导入后把 Scale Factor 设成 1。如果模型来自不同来源,比例不一致,可以在导入设置里单独调,但更推荐在建模阶段统一。
// ModelImportChecker.cs // 挂在空物体上,导入后自动检查模型包围盒和材质数量 using UnityEngine; public class ModelImportChecker : MonoBehaviour { void Start() { // 获取所有子物体的 Renderer Renderer[] renderers = GetComponentsInChildren<Renderer>(); if (renderers.Length == 0) { Debug.LogWarning("模型下没有找到任何 Renderer,检查 FBX 是否导入成功"); return; } // 计算整体包围盒 Bounds bounds = renderers[0].bounds; foreach (var r in renderers) { bounds.Encapsulate(r.bounds); } Debug.Log($"模型包围盒尺寸: {bounds.size}, 中心点: {bounds.center}"); // 统计材质数量 var materials = new System.Collections.Generic.HashSet<Material>(); foreach (var r in renderers) { foreach (var m in r.sharedMaterials) { if (m != null) materials.Add(m); } } Debug.Log($"独立材质数量: {materials.Count},超过 20 个建议合并"); } }这段代码的逻辑很简单:遍历所有 Renderer,算出一个能包住整个模型的包围盒,同时统计独立材质数量。包围盒尺寸用来判断比例是否合理,材质数量用来判断是否需要做图集合并。参数方面,如果包围盒某个轴特别小(比如小于 0.01),说明模型可能被压扁了;材质超过 20 个,移动端就要考虑用 TexturePacking 合并贴图。
2.2 相机控制:轨道旋转与聚焦的取舍
展示类场景的相机控制,我见过三种做法:自己写鼠标事件、用 Cinemachine、用现成的轨道控制器。自己写最灵活但容易出 bug,Cinemachine 适合做镜头动画但不适合自由观察,轨道控制器最稳但需要调参数。
我一般会写一个轻量的轨道控制器,核心逻辑是:鼠标左键旋转、右键平移、滚轮缩放,同时限制俯仰角避免翻转。下面是一个最小实现。
// OrbitCamera.cs // 轻量轨道相机,支持旋转、平移、缩放 using UnityEngine; public class OrbitCamera : MonoBehaviour { public Transform target; // 围绕的目标点 public float rotateSpeed = 5f; // 旋转速度 public float zoomSpeed = 2f; // 缩放速度 public float panSpeed = 0.5f; // 平移速度 public float minDistance = 0.5f; // 最近距离 public float maxDistance = 20f; // 最远距离 public float minPitch = -80f; // 最小俯仰角 public float maxPitch = 80f; // 最大俯仰角 private float distance = 5f; private float yaw = 0f; private float pitch = 20f; void Start() { if (target == null) { // 没有目标就创建一个空物体放在原点 GameObject go = new GameObject("CameraTarget"); target = go.transform; } // 初始化距离为当前距离 distance = Vector3.Distance(transform.position, target.position); } void LateUpdate() { // 旋转:按住左键 if (Input.GetMouseButton(0)) { yaw += Input.GetAxis("Mouse X") * rotateSpeed; pitch -= Input.GetAxis("Mouse Y") * rotateSpeed; pitch = Mathf.Clamp(pitch, minPitch, maxPitch); } // 平移:按住右键 if (Input.GetMouseButton(1)) { float x = -Input.GetAxis("Mouse X") * panSpeed * distance; float y = -Input.GetAxis("Mouse Y") * panSpeed * distance; target.Translate(new Vector3(x, y, 0), Space.Self); } // 缩放:滚轮 float scroll = Input.GetAxis("Mouse ScrollWheel"); distance -= scroll * zoomSpeed * distance; distance = Mathf.Clamp(distance, minDistance, maxDistance); // 计算相机位置 Quaternion rotation = Quaternion.Euler(pitch, yaw, 0); Vector3 position = target.position - rotation * Vector3.forward * distance; transform.rotation = rotation; transform.position = position; } }这段代码的关键参数是minPitch和maxPitch,限制在 -80 到 80 度之间,避免相机翻到模型底下导致方向感丢失。panSpeed乘以distance是为了让平移速度随距离变化,远看时移动快,近看时移动慢,手感更自然。LateUpdate里更新相机,保证在模型动画之后执行,避免抖动。
2.3 场景光照与后处理的低成本方案
展示场景不需要太复杂的光照,我一般用一盏方向光加环境光,再配一个后处理 Volume 做轻微的抗锯齿和色彩调整。方向光的角度设成 45 度俯角,强度 1.2 左右,环境光用 Gradient 模式,天空色偏冷、地面色偏暖。后处理里 Bloom 强度不要超过 0.5,否则模型边缘会糊。
如果模型是金属材质,记得在导入设置里勾选“Read/Write Enabled”,否则运行时改材质属性会报错。这个坑我踩过不止一次,模型看起来没问题,一改颜色就变黑,查半天才发现是导入设置的问题。
3. 3D 标注系统:让文字和模型一起转
3.1 标注的两种挂载方式:世界空间与屏幕空间
3D 标注的核心问题是:文字要跟着模型转,但不能被模型挡住,也不能永远背对镜头。常见做法有两种:世界空间 Canvas 和屏幕空间 Canvas 加世界坐标转换。
世界空间 Canvas 的优点是实现简单,把 Canvas 的 Render Mode 设成 World Space,然后作为模型的子物体就行。缺点是文字会随模型旋转,转到背面就看不见了。屏幕空间 Canvas 的优点是文字永远面向镜头,但需要每帧计算模型上某个点的屏幕坐标,性能开销稍大。
我一般用屏幕空间方案,因为展示场景里用户最关心的是“这个零件叫什么”,而不是“这个文字在三维空间里的朝向”。下面是一个标注管理器的实现。
// AnnotationManager.cs // 管理所有标注点,每帧更新屏幕位置 using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class AnnotationManager : MonoBehaviour { [System.Serializable] public class AnnotationData { public string label; // 标注文字 public Transform anchor; // 模型上的锚点 public Vector3 offset; // 屏幕偏移 } public Canvas uiCanvas; // 屏幕空间 Canvas public GameObject labelPrefab; // 标注预制体(Text + Image) public List<AnnotationData> annotations = new List<AnnotationData>(); private List<RectTransform> labelRects = new List<RectTransform>(); private Camera mainCam; void Start() { mainCam = Camera.main; foreach (var data in annotations) { GameObject go = Instantiate(labelPrefab, uiCanvas.transform); go.GetComponentInChildren<Text>().text = data.label; labelRects.Add(go.GetComponent<RectTransform>()); } } void LateUpdate() { for (int i = 0; i < annotations.Count; i++) { var data = annotations[i]; if (data.anchor == null) continue; // 世界坐标转屏幕坐标 Vector3 screenPos = mainCam.WorldToScreenPoint(data.anchor.position); // 判断是否在相机前方 bool isBehind = screenPos.z < 0; labelRects[i].gameObject.SetActive(!isBehind); if (!isBehind) { // 加上偏移,避免文字盖住锚点 screenPos += data.offset; labelRects[i].position = screenPos; } } } }这段代码的核心是WorldToScreenPoint,把模型上的锚点转成屏幕坐标,然后设置 UI 元素的位置。screenPos.z < 0用来判断锚点是否在相机背面,如果在背面就隐藏标注,避免出现“文字飘在模型外面”的诡异现象。offset参数用来微调文字位置,一般设成 (0, 30, 0) 让文字浮在锚点上方。
3.2 标注的遮挡处理:射线检测与透明度渐变
标注文字被模型挡住是常见问题。如果不管,用户会看到文字穿过模型显示出来,很出戏。我一般用射线检测来判断锚点是否被遮挡,如果被遮挡就把标注透明度降低。
// 在 AnnotationManager 的 LateUpdate 里加入遮挡检测 // 需要给模型层设置 LayerMask public LayerMask modelLayer; public float occludedAlpha = 0.3f; // 在设置位置之前加入 Vector3 dir = data.anchor.position - mainCam.transform.position; float dist = dir.magnitude; RaycastHit hit; bool occluded = Physics.Raycast(mainCam.transform.position, dir.normalized, out hit, dist, modelLayer); // 设置透明度 CanvasGroup group = labelRects[i].GetComponent<CanvasGroup>(); if (group == null) group = labelRects[i].gameObject.AddComponent<CanvasGroup>(); group.alpha = occluded ? occludedAlpha : 1f;这段代码从相机向锚点发射一条射线,如果打到模型层上的物体,说明锚点被遮挡。modelLayer需要手动设置,只包含模型所在的层,避免射线打到 UI 或其他物体。occludedAlpha设成 0.3 左右,既能让用户知道标注还在,又不会太抢眼。
3.3 标注数据的配置与动态增删
实际项目里,标注数据一般从 JSON 或 ScriptableObject 读取。我倾向于用 ScriptableObject,因为可以在 Inspector 里直接编辑,不用手写 JSON。下面是一个简单的配置类。
// AnnotationConfig.cs // 标注配置,可在 Inspector 里编辑 using UnityEngine; [CreateAssetMenu(fileName = "AnnotationConfig", menuName = "展示/标注配置")] public class AnnotationConfig : ScriptableObject { [System.Serializable] public class Item { public string label; public string anchorName; // 模型上锚点的名字 public Vector3 offset; } public Item[] items; }用的时候在AnnotationManager里根据anchorName找到对应的 Transform,然后填充annotations列表。这样非技术同事也能在 Inspector 里改标注文字,不用动代码。动态增删的话,调用Instantiate和Destroy就行,记得同步维护labelRects列表。
4. 拆装动画:从爆炸图到分步装配
4.1 拆装动画的两种实现路径:Animator 与代码驱动
拆装动画有两种做法:用 Unity 的 Animator 做关键帧动画,或者用代码控制零件位移。Animator 适合固定的拆装顺序,做一次就能反复播;代码驱动适合动态调整拆装距离和顺序,灵活性更高。
我一般用代码驱动,因为展示场景里经常需要根据用户操作调整拆装进度。核心思路是:记录每个零件的初始位置和拆装目标位置,用插值控制进度。
// DisassemblyController.cs // 拆装控制器,支持整体拆装和单零件拆装 using System.Collections.Generic; using UnityEngine; public class DisassemblyController : MonoBehaviour { [System.Serializable] public class Part { public Transform transform; // 零件 public Vector3 explodeDir; // 拆装方向(本地坐标) public float explodeDistance; // 拆装距离 [HideInInspector] public Vector3 originPos; [HideInInspector] public Vector3 targetPos; } public List<Part> parts = new List<Part>(); public float duration = 1f; // 动画时长 public AnimationCurve easeCurve; // 缓动曲线 private float progress = 0f; // 0=装配,1=拆开 private float targetProgress = 0f; private float velocity = 0f; void Start() { foreach (var part in parts) { part.originPos = part.transform.localPosition; // 目标位置 = 初始位置 + 方向 * 距离 part.targetPos = part.originPos + part.explodeDir.normalized * part.explodeDistance; } } void Update() { // 平滑过渡 progress = Mathf.SmoothDamp(progress, targetProgress, ref velocity, duration * 0.3f); foreach (var part in parts) { float t = easeCurve.Evaluate(progress); part.transform.localPosition = Vector3.Lerp(part.originPos, part.targetPos, t); } } // 整体拆开 public void ExplodeAll() { targetProgress = 1f; } // 整体装配 public void AssembleAll() { targetProgress = 0f; } // 单个零件拆开 public void ExplodePart(int index) { if (index < 0 || index >= parts.Count) return; // 这里可以扩展成单独控制某个零件的进度 // 简单做法是直接设置该零件的位置 parts[index].transform.localPosition = parts[index].targetPos; } }这段代码的核心是SmoothDamp和Vector3.Lerp。SmoothDamp让进度平滑变化,避免生硬跳变;Lerp根据进度插值位置。easeCurve用来控制缓动效果,一般用 EaseInOut 曲线,开始和结束慢、中间快。explodeDir是本地坐标下的方向,这样模型旋转后拆装方向依然正确。
4.2 拆装顺序与依赖关系:用队列控制先后
有些设备的拆装是有顺序的,比如先拆外壳再拆内部零件。我一般用一个简单的队列来控制。
// 在 DisassemblyController 里加入顺序控制 public List<int> explodeOrder = new List<int>(); // 拆装顺序索引 private int currentStep = 0; public void NextStep() { if (currentStep >= explodeOrder.Count) return; int index = explodeOrder[currentStep]; // 把当前零件加入拆装列表 // 这里简化处理,直接设置目标进度 currentStep++; } public void ResetOrder() { currentStep = 0; targetProgress = 0f; }实际项目里,我会把每个零件的拆装进度单独存一个值,然后用一个协程按顺序推进。这样用户点“下一步”的时候,只拆当前步骤的零件,前面的保持拆开状态。
4.3 拆装动画的性能优化:合批与 LOD
零件多了之后,拆装动画会掉帧。我一般做两件事:合并材质减少 draw call,给远处的零件加 LOD。合并材质可以用 Unity 的 TexturePacking,把多个小贴图打成一个图集,然后所有零件共用一个材质。LOD 的话,给每个零件加一个 LOD Group,远处显示低模,近处显示高模。
还有一个容易忽略的点:拆装动画更新的是localPosition,如果零件层级很深,每次更新都会触发 Transform 的脏标记,影响性能。我一般会把需要拆装的零件放在同一层级下,减少层级嵌套。
5. 避坑与排查:那些让我加班到凌晨的问题
5.1 模型导入后比例不对,相机拉近就穿模
现象:模型导入后看起来正常,但相机拉近到一定程度就穿到模型内部,或者模型突然变得巨大。
原因:FBX 导出时的单位比例和 Unity 的默认单位不一致。Unity 默认 1 单位 = 1 米,但有些建模软件默认 1 单位 = 1 厘米。
解决:在导入设置里把 Scale Factor 调成 0.01 或 100,或者在建模软件里统一单位。我一般会在导入后跑一次ModelImportChecker,看包围盒尺寸是否合理。如果包围盒某个轴小于 0.01 或大于 1000,基本就是比例问题。
5.2 标注文字被模型挡住,射线检测失效
现象:标注文字明明在模型前面,但射线检测判断为遮挡,透明度一直很低。
原因:射线检测的 LayerMask 设置不对,或者锚点位置在模型内部。还有一种可能是模型的 Collider 没有正确生成。
解决:检查 LayerMask 是否只包含模型层,检查锚点的世界坐标是否在模型表面。如果模型没有 Collider,可以手动加一个 MeshCollider,或者用Physics.Raycast的重载版本,指定QueryTriggerInteraction.Ignore。
5.3 拆装动画播完后零件位置偏移
现象:拆装动画播完后,零件没有回到原位,或者位置有微小偏移。
原因:SmoothDamp的velocity没有重置,或者progress没有精确到达 0 或 1。
解决:在ExplodeAll和AssembleAll里把velocity置零,并且在Update里判断Mathf.Abs(progress - targetProgress) < 0.001f时直接设置progress = targetProgress。另外,originPos要在Start里记录,不要在Awake里记录,因为Awake时模型可能还没初始化。
5.4 移动端拆装动画卡顿,帧率掉到 20 以下
现象:在编辑器里跑得很流畅,打包到移动端就卡。
原因:材质数量太多,draw call 过高;或者拆装动画每帧更新太多 Transform。
解决:用 TexturePacking 合并材质,把 draw call 降到 50 以下;拆装动画改成每两帧更新一次,或者用 Job System 并行计算位置。还有一个取巧的办法:拆装动画播完后把脚本禁用,需要时再启用。
5.5 标注文字在不同分辨率下位置偏移
现象:在 1080p 屏幕上标注位置正常,在 2K 或 4K 屏幕上标注偏了。
原因:屏幕空间 Canvas 的CanvasScaler没有设置成“Scale With Screen Size”,或者WorldToScreenPoint返回的坐标没有考虑 DPI。
解决:把CanvasScaler的Ui Scale Mode设成Scale With Screen Size,参考分辨率设成 1920x1080,匹配模式选Match Width Or Height,一般设 0.5。这样 UI 会按比例缩放,标注位置就不会偏。
6. 进阶技巧:用状态机管理展示流程
前面讲的都是单个功能的实现,实际项目里往往需要把模型展示、标注、拆装串成一条流程。我一般用一个简单的状态机来管理,状态包括:自由观察、标注模式、拆装模式、装配模式。每个状态有自己的进入和退出逻辑,比如进入拆装模式时禁用相机旋转,退出时恢复。
// ShowStateMachine.cs // 展示流程状态机 using UnityEngine; public class ShowStateMachine : MonoBehaviour { public enum State { FreeView, Annotation, Disassemble, Assemble } public State currentState = State.FreeView; public OrbitCamera orbitCamera; public AnnotationManager annotationManager; public DisassemblyController disassemblyController; void Start() { EnterState(State.FreeView); } public void EnterState(State newState) { // 退出当前状态 ExitState(currentState); currentState = newState; // 进入新状态 switch (newState) { case State.FreeView: orbitCamera.enabled = true; annotationManager.gameObject.SetActive(false); disassemblyController.AssembleAll(); break; case State.Annotation: orbitCamera.enabled = true; annotationManager.gameObject.SetActive(true); break; case State.Disassemble: orbitCamera.enabled = false; disassemblyController.ExplodeAll(); break; case State.Assemble: orbitCamera.enabled = false; disassemblyController.AssembleAll(); break; } } void ExitState(State oldState) { // 这里可以加一些清理逻辑 // 比如停止动画、重置参数 } }这个状态机的核心是EnterState和ExitState,每个状态切换时先清理旧状态,再初始化新状态。orbitCamera.enabled用来控制相机是否可操作,拆装模式下禁用相机旋转,避免用户误操作。annotationManager.gameObject.SetActive用来控制标注的显示隐藏。
验证这套流程是否正常,我一般会做三个测试:第一,从自由观察切到标注模式,标注是否正常显示;第二,从标注模式切到拆装模式,标注是否隐藏、拆装是否播放;第三,从拆装模式切回自由观察,零件是否回到原位、相机是否恢复控制。如果这三个测试都通过,基本就没大问题了。
还有一个技巧:用ScriptableObject把状态配置存起来,比如每个状态对应的相机距离、标注透明度、拆装速度。这样调整参数不用改代码,直接在 Inspector 里改就行。我一般会建一个ShowConfig资产,里面存所有可调参数,运行时读取。
最后说一个我自己的习惯:每次做完一个展示 Demo,我都会在真机上跑一遍,用最低端的设备测。编辑器里再流畅,真机上也可能翻车。尤其是拆装动画,零件一多,移动端很容易掉帧。如果真机测试过不了,宁可减少零件数量,也不要硬扛。希望帮到你。
本文还有配套的精品资源,点击获取