
简介这份资源面向Unity开发者、教学课件制作者与产品展示设计人员聚焦3D模型展示中的交互呈现问题涵盖模型导入优化、3D标注、环绕相机、步骤列表与拆装动画等核心知识点适合具备一定Unity基础、希望提升展示类应用互动性的中级开发者。压缩包共4300个文件约65.71MB以cs脚本、meta配置、png贴图、md说明文档为主另含mat材质、prefab预制体、shader着色器、fbx模型与unitypackage资源包覆盖从逻辑控制到视觉呈现的完整工程结构。目前已有3749人学习下载。读者可从中获得一套可直接运行的模型展示工程理解标注随模型移动的实现方式、目标相机360度环绕脚本的编写思路以及借助Animator状态机与Rigidbody物理引擎构建拆装动画的完整流程便于迁移到教学演示、产品拆解说明或AR/VR交互场景中。1. 从一份工业手册到可交互样机Unity 3D模型展示到底在做什么很多做设备培训、产品手册、展馆交互的团队都遇到过同一个尴尬花大价钱做的三维模型最后只能导出一段视频客户想转个角度看内部结构只能重新录一遍。Unity 3D模型展示解决的正是这件事——把静态模型变成能转、能点、能拆、能标注的实时交互场景。它适合三类人做工业仿真和数字孪生的开发者、做产品可视化交付的技术美术、以及需要把装配流程讲清楚的技术支持团队。核心链路其实就四步模型导入与材质修复、相机与交互控制、3D标注系统、拆装动画状态机。这四步里模型导入决定下限标注和拆装动画决定上限。下面按我实际交付过几个设备展示项目的顺序把每一步拆开讲包括参数怎么设、坑在哪、为什么这么选。2. 模型导入与材质修复从建模软件到 Unity 的第一道坎2.1 为什么 FBX 导入后材质会变成紫红色Unity 材质变成紫红色是新手最常遇到的翻车现场本质是 Shader 找不到或渲染管线不匹配。如果你用的是 URP 或 HDRP 项目而模型自带的材质用的是 Built-in 管线的 Standard Shader导入后就会显示为紫红色。常见做法是导入前统一在建模软件里把材质球换成中性灰导入后在 Unity 里重新赋 URP/Lit 材质。另一个原因是 FBX 里嵌入了不兼容的 Shader 引用需要在 Inspector 的 Materials 选项卡里把 Location 从 Embedded 改成 Use External Materials让 Unity 重新生成材质文件。// 批量检测场景中所有使用错误 Shader 的材质 using UnityEngine; using UnityEditor; using System.Collections.Generic; public class ShaderChecker : EditorWindow { [MenuItem(Tools/检查紫红材质)] static void CheckPinkMaterials() { var renderers FindObjectsOfTypeRenderer(); var badList new ListMaterial(); foreach (var r in renderers) { foreach (var mat in r.sharedMaterials) { // URP 项目里 Lit Shader 名称包含 Universal Render Pipeline if (mat ! null mat.shader.name Hidden/InternalErrorShader) { badList.Add(mat); Debug.LogWarning($紫红材质: {r.gameObject.name} - {mat.name}, r.gameObject); } } } Debug.Log($共发现 {badList.Count} 个异常材质); } }这段编辑器脚本遍历场景中所有 Renderer检查 Shader 名称是否为 Unity 内置的错误 Shader。参数上不需要额外配置挂到 Editor 文件夹下即可通过菜单调用。逻辑说明sharedMaterials比materials更安全不会在编辑模式下实例化材质副本。发现异常后手动替换为Universal Render Pipeline/Lit或HDRP/Lit再根据需求调整 Metallic 和 Smoothness。2.2 模型面数与 Draw Call 的取舍参数工业模型动辄几十万面直接扔进 Unity 移动端必卡。我一般会做三级 LOD近距离 100% 面数中距离 40%远距离 15%。在 FBX 导入设置里Mesh Compression 设为 Off 或 Low避免顶点精度丢失导致标注点偏移。Optimize Mesh 勾选Weld Vertices 根据模型是否硬边决定——硬边多的机械件不要勾否则法线会糊。参数建议值说明Mesh CompressionOff / Low压缩过高会导致标注锚点漂移Optimize Mesh勾选重排顶点顺序提升缓存命中Generate Colliders按需拆装动画需要碰撞检测时勾选Read/Write关闭运行时不需要修改网格就关掉省内存NormalsImport机械件保留硬边法线TangentsCalculate需要法线贴图时计算Draw Call 优化上静态部件勾选 Static Batching动态拆装部件用 GPU Instancing 合批相同材质。注意GPU Instancing 要求材质相同且开启 Enable GPU Instancing如果每个零件材质球不同合批会失效。我一般会把同色零件合并成一个材质用顶点色区分细节。2.3 用脚本控制模型显隐与层级切换展示场景经常需要按分类显示不同子系统比如只看液压部分或只看电气部分。直接 SetActive 会导致大量 GC更好的做法是控制 Renderer 的 enabled 或使用 CanvasGroup 思路做透明度过渡。using UnityEngine; using System.Collections.Generic; public class PartGroupController : MonoBehaviour { [System.Serializable] public class PartGroup { public string groupName; public ListRenderer renderers; } public ListPartGroup groups; public float fadeSpeed 5f; private DictionaryRenderer, float targetAlpha new DictionaryRenderer, float(); void Start() { foreach (var g in groups) { foreach (var r in g.renderers) { targetAlpha[r] 1f; } } } public void ShowGroup(string name, bool show) { foreach (var g in groups) { if (g.groupName ! name) continue; foreach (var r in g.renderers) { targetAlpha[r] show ? 1f : 0f; } } } void Update() { foreach (var kv in targetAlpha) { var mat kv.Key.material; Color c mat.color; c.a Mathf.Lerp(c.a, kv.Value, Time.deltaTime * fadeSpeed); mat.color c; kv.Key.enabled c.a 0.01f; } } }逻辑说明用字典记录每个 Renderer 的目标透明度Update 里做插值避免频繁 SetActive 引起的层级重建。参数上fadeSpeed控制淡入淡出速度一般 3 到 8 之间。注意材质必须支持透明混合URP/Lit 要把 Surface Type 设为 Transparent。如果模型面数很高逐材质修改颜色会打断合批建议只对关键部件用这套其余直接开关。3. 3D标注系统把文字钉在模型上的三种方案3.1 世界空间 Canvas 与屏幕空间标注的选型3D标注的核心问题是标注点跟着模型转但文字要始终面向相机。常见三种方案世界空间 Canvas 贴在模型表面、屏幕空间 Overlay 加射线检测、以及用 LineRenderer 拉引线到固定 UI 面板。世界空间 Canvas 实现最简单但文字会随模型缩放变形屏幕空间方案文字清晰但需要每帧计算屏幕坐标标注多时有性能开销。我一般用混合方案标注点用世界空间小球文字用屏幕空间 UI 池管理。using UnityEngine; using UnityEngine.UI; public class AnnotationPoint : MonoBehaviour { public string title; public string content; public RectTransform uiElement; // 屏幕空间 UI 预制体 public Vector3 offset new Vector3(0, 0.1f, 0); private Camera mainCam; void Start() { mainCam Camera.main; if (uiElement ! null) { uiElement.gameObject.SetActive(false); } } void Update() { if (uiElement null || mainCam null) return; Vector3 worldPos transform.position offset; Vector3 screenPos mainCam.WorldToScreenPoint(worldPos); // 判断是否在相机前方 bool isVisible screenPos.z 0 screenPos.x 0 screenPos.x Screen.width screenPos.y 0 screenPos.y Screen.height; uiElement.gameObject.SetActive(isVisible); if (isVisible) { uiElement.position screenPos; } } public void OnClick() { // 点击标注点展开详情 AnnotationPanel.Instance.Show(title, content); } }逻辑说明每个标注点挂一个屏幕空间 UI 引用Update 里做世界坐标到屏幕坐标的转换并判断是否在视口内。参数上offset用于把标注点从模型表面往外推一点避免被模型遮挡。注意WorldToScreenPoint返回的 z 值小于 0 表示在相机背后必须过滤。如果标注点很多建议用对象池管理 UI 元素不要每个点都 Instantiate。3.2 标注点遮挡处理与引线绘制标注点被模型挡住时如果还显示文字会让人困惑。常见做法是发射一条从相机到标注点的射线检测中间是否有碰撞体。有遮挡就把标注点变半透明或隐藏。引线用 LineRenderer从标注点连到 UI 面板宽度随距离变化。using UnityEngine; public class AnnotationOcclusion : MonoBehaviour { public LayerMask modelLayer; public float fadeAlpha 0.3f; public LineRenderer line; private Renderer[] renderers; private Camera mainCam; void Start() { mainCam Camera.main; renderers GetComponentsInChildrenRenderer(); } void Update() { if (mainCam null) return; Vector3 dir transform.position - mainCam.transform.position; float dist dir.magnitude; bool occluded Physics.Raycast(mainCam.transform.position, dir.normalized, out RaycastHit hit, dist, modelLayer); float alpha occluded ? fadeAlpha : 1f; foreach (var r in renderers) { var mat r.material; Color c mat.color; c.a alpha; mat.color c; } if (line ! null) { line.enabled !occluded; } } }逻辑说明从相机向标注点发射射线如果打到模型层说明被遮挡。参数上modelLayer只勾选模型层避免射线打到 UI 或其他标注点。fadeAlpha一般设 0.2 到 0.4太低看不见太高和没遮挡区分不明显。注意射线检测每帧执行标注点多时建议每 3 帧检测一次或者用 Job System 并行。3.3 标注数据的配置化与热更新硬编码标注内容在项目后期是灾难。我一般把标注数据抽成 JSON 或 ScriptableObject运行时加载。JSON 方便非技术人员编辑ScriptableObject 方便在 Inspector 里拖拽引用。{ annotations: [ { id: pump_01, title: 液压泵, content: 额定压力 25MPa流量 40L/min, position: { x: 1.2, y: 0.8, z: 0.5 }, groupId: hydraulic }, { id: valve_02, title: 换向阀, content: 三位四通中位机能 O 型, position: { x: 0.6, y: 1.1, z: -0.3 }, groupId: hydraulic } ] }using UnityEngine; using System; [Serializable] public class AnnotationData { public string id; public string title; public string content; public Vector3 position; public string groupId; } [Serializable] public class AnnotationRoot { public AnnotationData[] annotations; } public class AnnotationLoader : MonoBehaviour { public TextAsset jsonFile; public GameObject annotationPrefab; void Start() { var root JsonUtility.FromJsonAnnotationRoot(jsonFile.text); foreach (var data in root.annotations) { var go Instantiate(annotationPrefab, data.position, Quaternion.identity); var point go.GetComponentAnnotationPoint(); point.title data.title; point.content data.content; } } }逻辑说明用JsonUtility解析 JSON注意它不支持字典和顶层数组所以包了一层AnnotationRoot。参数上position是模型本地坐标如果模型有缩放需要乘以缩放系数。热更新场景下把 JSON 放到 StreamingAssets 或远程 CDN启动时下载替换。注意JsonUtility对中文支持没问题但字段名必须和 JSON 键完全一致。4. 拆装动画状态机、时间轴与脚本控制的组合拳4.1 用 Animator 状态机管理拆装步骤拆装动画的本质是多个零件按顺序移动、旋转、显隐。最直接的方式是每个零件做一个 Animation Clip然后用 Animator 状态机串联。但零件多了之后状态机会变成蜘蛛网。我一般用脚本控制时间轴或者用 Playable Director 做序列。using UnityEngine; using System.Collections; public class DisassemblyController : MonoBehaviour { [System.Serializable] public class Step { public string stepName; public Transform part; public Vector3 targetPosition; public Vector3 targetRotation; public float duration 1f; } public Step[] steps; public float stepDelay 0.3f; private bool isPlaying; public void PlayForward() { if (!isPlaying) StartCoroutine(PlaySteps(true)); } public void PlayBackward() { if (!isPlaying) StartCoroutine(PlaySteps(false)); } IEnumerator PlaySteps(bool forward) { isPlaying true; int start forward ? 0 : steps.Length - 1; int end forward ? steps.Length : -1; int dir forward ? 1 : -1; for (int i start; i ! end; i dir) { var step steps[i]; Vector3 fromPos step.part.localPosition; Vector3 fromRot step.part.localEulerAngles; Vector3 toPos forward ? step.targetPosition : fromPos; Vector3 toRot forward ? step.targetRotation : fromRot; float t 0; while (t 1) { t Time.deltaTime / step.duration; step.part.localPosition Vector3.Lerp(fromPos, toPos, t); step.part.localEulerAngles Vector3.Lerp(fromRot, toRot, t); yield return null; } yield return new WaitForSeconds(stepDelay); } isPlaying false; } }逻辑说明每个 Step 定义零件、目标位置、目标旋转和时长协程按顺序插值。参数上duration控制单步动画时长stepDelay控制步骤间停顿。注意localEulerAngles插值在超过 180 度时会有最短路径问题需要自己处理角度环绕。如果拆装步骤超过 20 步建议改用 Playable Director 或 DOTween 序列协程嵌套太深不好维护。4.2 拆装动画的爆炸视图与相机聚焦爆炸视图是拆装动画的常见变体所有零件沿法线方向散开。相机聚焦则是点击某个零件时相机平滑移动到该零件附近。这两者结合能大幅提升展示效果。using UnityEngine; public class CameraFocus : MonoBehaviour { public Transform target; public float distance 2f; public float height 1f; public float smoothTime 0.3f; private Vector3 velocity; void LateUpdate() { if (target null) return; Vector3 desiredPos target.position - target.forward * distance Vector3.up * height; transform.position Vector3.SmoothDamp(transform.position, desiredPos, ref velocity, smoothTime); transform.LookAt(target); } public void FocusOn(Transform newTarget) { target newTarget; } }逻辑说明SmoothDamp做平滑跟随避免相机瞬移。参数上distance和height决定相机相对目标的位置smoothTime越小跟随越快。注意LateUpdate里执行确保在模型动画之后更新。如果目标零件在拆装过程中移动相机会自动跟随但可能产生眩晕感建议聚焦后锁定目标位置。4.3 拆装顺序的约束与校验实际装配有先后顺序比如必须先拆外壳才能拆内部零件。如果用户乱点动画会穿模。我一般加一个前置依赖检查每个步骤记录依赖的步骤 ID不满足就提示或禁止。using UnityEngine; using System.Collections.Generic; public class StepDependency : MonoBehaviour { [System.Serializable] public class StepInfo { public string id; public Liststring dependencies; public GameObject part; } public ListStepInfo stepInfos; private HashSetstring completed new HashSetstring(); public bool CanExecute(string stepId) { var info stepInfos.Find(s s.id stepId); if (info null) return false; foreach (var dep in info.dependencies) { if (!completed.Contains(dep)) return false; } return true; } public void MarkCompleted(string stepId) { completed.Add(stepId); } public void ResetAll() { completed.Clear(); } }逻辑说明用 HashSet 记录已完成步骤CanExecute检查依赖是否满足。参数上依赖列表在 Inspector 里配置注意不要配成循环依赖。如果步骤多建议用图结构做拓扑排序启动时校验是否有环。注意ResetAll在重置拆装时调用清空完成记录。5. 避坑与排查拆装展示项目里最容易翻车的五件事5.1 标注点随模型缩放漂移现象模型整体缩放后标注点位置偏移不再贴合表面。原因标注点坐标是本地坐标但缩放后世界坐标变了而标注点没有跟随缩放。解决把标注点作为模型的子物体或者每帧根据模型缩放系数重新计算世界坐标。如果标注点独立于模型用TransformPoint转换时要把缩放乘进去。5.2 拆装动画播放时材质变紫红现象动画播放到某个零件时该零件突然变成紫红色。原因动画剪辑里包含了材质属性轨道但目标材质在运行时被替换或 Shader 不兼容。解决检查 Animation 窗口里是否有材质轨道删除或改为只控制 Transform。如果必须控制材质确保动画里引用的材质和运行时一致。5.3 移动端标注文字模糊现象在手机上标注文字边缘模糊看不清。原因屏幕空间 Canvas 的 Canvas Scaler 没设对或者 UI 纹理压缩过度。解决Canvas Scaler 设为 Scale With Screen Size参考分辨率 1920x1080Match 设 0.5。文字用 TextMeshPro字体图集设 1024x1024关闭 Crunch Compression。5.4 拆装步骤多了之后帧率骤降现象拆装步骤超过 30 步后帧率从 60 掉到 20。原因每个步骤的协程都在 Update 里插值加上每帧修改 Transform 导致合批失效。解决把不动的零件设为 Static动的零件用 DOTween 或 Playable 统一驱动减少协程数量。另外拆装过程中关闭阴影投射结束后再打开。5.5 射线检测标注点被 UI 挡住现象点击标注点没反应或者点到了 UI 上。原因射线检测没有区分 UI 和 3D 物体EventSystem 的 Raycast 被 UI 拦截。解决在标注点的 Collider 上加PhysicsRaycaster或者用EventSystem.current.IsPointerOverGameObject()判断是否点在 UI 上。如果标注点用 UI 实现确保 Canvas 的 Graphic Raycaster 和 3D 射线分开处理。6. 进阶技巧用 ScriptableObject 做可配置的展示流程项目交付后客户经常要改标注内容、调整拆装顺序、换模型。如果每次都要改代码重新打包维护成本太高。我现在的做法是把整个展示流程抽成 ScriptableObject 配置运行时加载非技术人员也能在 Unity 里编辑。using UnityEngine; using System.Collections.Generic; [CreateAssetMenu(fileName ShowcaseConfig, menuName 展示/配置)] public class ShowcaseConfig : ScriptableObject { [System.Serializable] public class AnnotationEntry { public string id; public string title; [TextArea] public string content; public Vector3 localPosition; public string groupId; } [System.Serializable] public class DisassemblyStep { public string stepId; public string displayName; public Liststring dependencies; public Vector3 moveOffset; public Vector3 rotateOffset; public float duration; } public ListAnnotationEntry annotations; public ListDisassemblyStep steps; public float cameraDistance 2f; public float cameraHeight 1f; }逻辑说明CreateAssetMenu让这个类可以在 Project 窗口右键创建资产。参数上moveOffset和rotateOffset是相对初始位置的偏移量比绝对坐标好维护。注意 ScriptableObject 在运行时修改不会持久化如果需要保存用户操作用 JSON 单独存。验证配置是否生效我一般写一个编辑器按钮一键检查所有标注点是否在模型包围盒内拆装步骤依赖是否有环。#if UNITY_EDITOR using UnityEditor; using UnityEngine; [CustomEditor(typeof(ShowcaseConfig))] public class ShowcaseConfigEditor : Editor { public override void OnInspectorGUI() { DrawDefaultInspector(); var config (ShowcaseConfig)target; if (GUILayout.Button(校验配置)) { var ids new System.Collections.Generic.HashSetstring(); foreach (var step in config.steps) { if (!ids.Add(step.stepId)) { Debug.LogError($重复步骤 ID: {step.stepId}); } foreach (var dep in step.dependencies) { if (!ids.Contains(dep)) { Debug.LogWarning($步骤 {step.stepId} 依赖 {dep}但 {dep} 尚未定义或顺序靠后); } } } Debug.Log($校验完成共 {config.steps.Count} 个步骤{config.annotations.Count} 个标注); } } } #endif逻辑说明编辑器脚本遍历步骤检查 ID 重复和依赖顺序。参数上不需要额外配置挂在 Editor 文件夹下即可。注意#if UNITY_EDITOR宏确保打包时不会编译进去。这个校验只能发现顺序问题循环依赖需要更复杂的图算法但实际项目里步骤不多人工检查也够用。最后说个血泪经验拆装动画的时长不要设得太短单步低于 0.5 秒用户看不清动作高于 2 秒又觉得拖沓。我一般设 0.8 到 1.2 秒步骤间停顿 0.3 秒。相机聚焦的平滑时间设 0.3 秒左右太快像瞬移太慢像慢动作。这些参数没有绝对标准但按这个范围调基本不会翻车。希望帮到你。本文还有配套的精品资源点击获取