☰
Unity VR/AR虚拟展厅开发全流程与真机验收指南
2026/11/3 2:55:52 网站建设 项目流程

简介:面向VR/AR开发者的虚拟展览实践脚本案例,适合希望系统梳理VR/AR应用开发流程的入门到进阶人员参考。内容围绕项目初始化与配置、场景搭建、交互功能开发、UI与音效设计、发布部署及后期维护展开,并补充3D建模、纹理制作、音频处理等扩展工具和性能优化、用户体验适配建议,可用于搭建开发框架、制定项目检查清单或对照排查实现环节。压缩包内共1个doc文档,约21KB,以文字说明形式组织完整流程,便于快速查阅和按需裁剪。已有807人学习下载,说明其在VR/AR展览类项目入门与流程参考方面具有一定关注度。读者可从中获得从环境配置、模型导入、灯光材质、交互逻辑到构建测试与版本维护的分阶段思路,并据此扩展为符合自身项目需求的虚拟展览方案。

1. 一份流程脚本撑不起虚拟展厅:先看清它补的是哪块短板

很多开发者拿到这份「VR/AR 开发脚本案例:创建虚拟展览体验」,第一反应是先去搜一堆 VR/AR 软件/插件,装一屏再说,然后照着六段流程从建文件夹一路往下走。走到第三步「编写交互逻辑」就卡住了——材料说要用 C# 写交互,却没说射线打中展品之后,事件该挂在哪个组件上、面板该由谁唤起。这份资源的价值其实不在代码,而在节点:它把从建工程、导模型、做交互、配 UI 音效,到构建、上架、收集反馈的完整链条列全了,是一张能对着打勾的施工清单。

适配人群也很明确:第一次做虚拟展厅的独立开发者、接了展厅外包但要排工期的两三人小队、以及做课程设计需要交付完整流程文档的学生。它不能替你写代码,但能让你少漏环节——尤其是「构建前该检查什么」这一段,绝大多数人是翻过车才补上的。

2. 项目初始化:把空工程配到能点亮头显的六个开关

流程脚本的第一段写得很轻:建文件夹、装引擎、设项目参数。真动手时这一步最容易埋雷,因为配错了不会立刻报错,而是等你构建完戴上一体机才发现画面是黑的。这一章把「初始化」拆成能验证的动作,每个动作都有可观察的结果。

2.1 引擎选型:脚本为什么把 Unity 排在前面

虚拟展览这类项目有个特点:交互逻辑不难,难在反复调。展台高度差 5 厘米、讲解音延迟半秒、按钮位置偏左——这些都要改几十版。所以选型的第一指标不是画面,是「一天能改几版」。

我一般会这么判断:如果展厅面积在 200 平米以内、展品数量几十件、交付周期三个月内,走 Unity + C# 更省心;如果场景是室外大范围、需要影视级材质、团队里有专职 TA,那 Unreal 的默认观感能省下不少调光照的时间。脚本把两者并列没错,但落地时得二选一,不要两边都试。

维度Unity(C#)Unreal(C++/蓝图)
改一版交互的等待保存脚本即进 Play,秒级C++ 改一次要等编译,分钟级
画面基线需要自己搭后处理和光照默认观感更讨喜
一体机包体可控在 200MB 内通常偏大,要专门裁剪
适合场景中小展厅、快速交付、课程设计大场景、高保真、有 TA 的团队

新手常犯的错是「先两个都装,比一比再说」,结果一周过去两个工程都只建到一半。我的建议是照脚本走,选定一个,把它跑到真机出画面,再谈别的。

2.2 插件挂载顺序:先 XR 管理,再交互工具包

初始化这段最容易翻车的地方是插件顺序。正确顺序是:引擎装好后先开 XR 插件管理,勾选 OpenXR 作为运行时后端;然后再导入交互工具包(提供射线、传送、抓取这些现成组件);最后建 XR Origin 角色。顺序反了会出现「组件能拖上去但运行时找不到输入设备」的玄学现象。

我习惯在工程里放一个自检脚本,每次换机器或升级引擎后跑一遍,比人肉翻设置面板可靠:

using UnityEditor; using UnityEngine; public class ExhibitProjectAudit : EditorWindow { [MenuItem("Tools/展厅项目/工程配置自检")] static void Audit() { // 1. 色彩空间:Gamma 下 PBR 材质会整体发灰,展品像蒙了层雾 if (PlayerSettings.colorSpace != ColorSpace.Linear) Debug.LogWarning("[配置] 色彩空间不是 Linear,先去 Player Settings 改"); // 2. 图形 API 顺序:一体机上优先 Vulkan,GLES3 作为兜底 var apis = PlayerSettings.GetGraphicsAPIs(BuildTarget.Android); Debug.Log("[配置] 图形 API 顺序: " + string.Join(" > ", apis)); // 3. 目标帧率:低于 72 会被系统强制重投影,画面拖影 if (Application.targetFrameRate < 72) Debug.LogWarning("[配置] targetFrameRate 低于 72"); // 4. 纹理压缩:ASTC 在一体机上比 ETC2 省显存 Debug.Log("[配置] 当前纹理压缩格式需在 Build Settings 里确认为 ASTC"); // 5. 场景清单:忘加进 Build Settings 是"构建后黑屏"的头号原因 var scenes = EditorBuildSettings.scenes; if (scenes.Length == 0) Debug.LogError("[配置] Build Settings 里没有任何场景"); else Debug.Log($"[配置] 构建场景数: {scenes.Length},首个: {scenes[0].path}"); } }

这段脚本做的是「把隐式配置变成显式输出」。ColorSpace.Linear影响的是材质表现,改完要重导光照;GetGraphicsAPIs只是打印,改顺序还得回 Player Settings;场景清单那条最关键,很多人构建出一个空包,戴上头显一片黑,查半小时才发现场景没勾。

提示:自检脚本放在 Editor 目录下,不要打进包里。

2.3 帧率、分辨率与抗锯齿:三个必须一起调的参数

流程脚本写「帧率通常推荐 60fps 或更高」,这句话在一体机上要修正:VR 的刷新率和普通应用不是一回事,设备强制要求 72Hz 或 90Hz,掉下去系统会用重投影补帧,代价是转头时边缘拖影。所以目标帧率应该贴着设备刷新率设,而不是设个 60 图省事。

分辨率同理。VR 里两只眼睛各渲染一次(或单通道立体渲染一次出双眼),像素量是普通应用的数倍。眼睛纹理分辨率倍率(eye texture resolution scale)设 1.0 是基准,1.2 能明显改善文字清晰度,超过 1.3 基本是拿帧率换心理安慰。

using UnityEngine; using UnityEngine.XR; public class XRRuntimeBootstrap : MonoBehaviour { [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] static void Init() { // 贴住一体机刷新率,宁可 72 稳,也不要 90 抖动 Application.targetFrameRate = 72; // VR 下必须关垂直同步,否则帧率被显示器刷新率锁死 QualitySettings.vSyncCount = 0; // MSAA 4x 在前向渲染下性价比最高,比拉高分辨率便宜 QualitySettings.antiAliasing = 4; // 眼睛纹理倍率:1.0 基准,1.2 兼顾文字清晰度和帧率 XRSettings.eyeTextureResolutionScale = 1.2f; // 阴影距离收短,展厅尺度 20 米足够,远处阴影肉眼分辨不出 QualitySettings.shadowDistance = 20f; } }

RuntimeInitializeOnLoadMethod的好处是不依赖场景里的任何物体,进包就执行,避免「某个场景忘了挂 Bootstrap」这种低级漏。vSyncCount = 0是硬性要求,不关的话帧率会被钳在 60 附近,转头立刻能感觉到顿。eyeTextureResolutionScale要根据实测调,一体机渲染压力大的时候先降到 1.0,别硬撑。

参数建议值调错的后果
targetFrameRate72(72Hz 设备)/ 90低于刷新率会持续重投影
vSyncCount0不关会被锁到 60
eyeTextureResolutionScale1.0 ~ 1.2过高直接掉帧,过低文字糊
antiAliasing4x MSAA不开边缘锯齿明显,开了过高吃带宽
shadowDistance15 ~ 25 米过大把预算浪费在看不见的地方

初始化这一步做完,验收标准很朴素:打包到真机,戴上能看到场景、转头不拖影、帧率稳定在你设的数值。达不到就别往下一步走,后面的问题会叠加。

3. 场景搭建:面数、贴图与光照的三笔预算怎么分

流程脚本的第二段是「导入模型、布置场景、加灯光材质」,写得很顺,但它没提预算是有限的。VR 的渲染预算大概只有普通 3D 应用的三分之一到二分之一,因为要跑双份视角还得维持 72 帧。这一章把场景搭建拆成三笔账:三角形、显存、光照。

3.1 模型导入的四个开关,比美术优化更管用

很多人拿到模型直接拖进工程,默认设置全开,结果包体大、内存高、加载慢。其实导入面板上有四个开关,改完立刻见效:

第一个是 Read/Write。除非你要在运行时用代码改顶点(比如做展品变形动画),否则关掉,它会让网格在内存里存两份。第二个是法线与切线:法线用 Import,切线用 Calculate Mikk,前者保细节,后者保证法线贴图正确。第三个是网格压缩,Medium 在展厅尺度下肉眼几乎看不出差别。第四个是 Optimize Mesh,勾上会重排顶点顺序,提升缓存命中率。

这套设置手点几十个模型很折磨,用后处理脚本一次搞定:

using UnityEditor; using UnityEngine; public class ExhibitModelImporter : AssetPostprocessor { // 只处理 Exhibits 目录下的模型,避免误伤场景道具 void OnPreprocessModel() { if (!assetPath.Contains("/Exhibits/")) return; var importer = (ModelImporter)assetImporter; // 关掉可读:内存直接省一半,代价是不能运行时改顶点 importer.isReadable = false; // 法线导入 + 切线自动计算,保证法线贴图方向正确 importer.importNormals = ModelImporterNormals.Import; importer.importTangents = ModelImporterTangents.CalculateMikk; // 中等压缩,展厅近距离观看看不出差异 importer.meshCompression = ModelImporterMeshCompression.Medium; // 顶点重排,提升 GPU 缓存命中 importer.optimizeMeshPolygons = true; importer.optimizeMeshVertices = true; // 关闭自动生成的碰撞体,展品用简单盒碰撞就够 importer.addCollider = false; } }

assetPath.Contains("/Exhibits/")是路径白名单,比按文件名判断稳,因为展厅项目素材来源杂,命名规范往往靠不住。addCollider = false这条容易被忽略:自动生成的网格碰撞体面数跟模型一样,一个 8000 面的展品配一个 8000 面的碰撞体,物理查询开销白白翻倍,而展品交互只需要一个包围盒。

3.2 三角形预算:一个 200 平米展厅大概能放多少面

我给个能直接抄的分配方式。目标帧率 72、单通道立体渲染、中端一体机,整个场景可见三角面控制在 15 万以内比较稳,留 30% 余量给特效和 UI。

内容单件三角面数量小计
主展品3000 ~ 800012约 6 万
展厅结构(墙地顶)500015000
展台、护栏、道具300 ~ 80030约 2 万
装饰件、标识牌20040约 8000
预留(特效、备用)——约 4 万

超了怎么办?不是让美术减面,而是先上 LOD。展品在 3 米外用 LOD1(面数减半),8 米外用 LOD2(减到 20%),展厅尺度下观众根本不会凑到 8 米外去看细节。LOD 的切换阈值按展厅实际参观动线定,不要用默认值——默认值是按通用场景给的,套到展厅上会出现「走到展品前突然跳一下」的穿帮。

注意:LOD 切换时如果两级的材质不同,会出现明显的明暗跳变。做法是两级共用同一套材质,只减面不减材质。

3.3 光照:能烘焙就别实时,补光靠 Light Probe

流程脚本写「添加合适的灯光与材质,提升真实感」,但没说实时阴影在 VR 里有多贵。一个实时方向光带阴影,等于整个场景再渲染一遍深度图,双眼中招。展厅里 90% 的光照是静态的,全部烘焙成光照贴图,运行时只留一到两个不带阴影的实时补光。

烘焙的坑在贴图分辨率。很多人图省事全场景设 40 texel/米,出来的光照贴图几百兆,包体爆炸。正确做法是按区域分级:展品周围 40,通道 20,天花板和角落 8。另外动态物体(比如可抓取的展品)拿不到烘焙光照,得靠 Light Probe 补,探针要铺在观众可能拿起的每一个位置,稀疏了会出现「物体从亮区走到暗区,颜色突然变」的断层。

using UnityEditor; using UnityEngine; public class LightmapBudget : EditorWindow { [MenuItem("Tools/展厅项目/光照贴图预算检查")] static void Check() { var settings = Lightmapping.GetLightingSettingsForScene( UnityEngine.SceneManagement.SceneManager.GetActiveScene()); // 展厅尺度下 20 texel/米是性价比拐点 if (settings.lightmapResolution > 40) Debug.LogWarning($"[光照] 贴图密度 {settings.lightmapResolution} 偏高,包体会膨胀"); // 静态物体必须勾 Contribute GI,否则烘焙完全不参与 int unbaked = 0; foreach (var r in Object.FindObjectsOfType<Renderer>()) { if (r.gameObject.isStatic && !r.receiveGI.Equals(ReceiveGI.Lightmaps)) unbaked++; } if (unbaked > 0) Debug.LogWarning($"[光照] {unbaked} 个静态物体未接收光照贴图"); } }

这段检查的是两个高频问题:密度过高和标记遗漏。lightmapResolution是每米多少 texel,不是总像素,所以调它要结合展厅实际尺寸想。第二个循环找的是「勾了 Static 但没参与 GI」的物体,这类物体会在烘焙后依然全黑或者全亮,非常显眼,但排查起来要一个个点,脚本一秒扫完。

场景搭建的验收标准:真机走一圈,帧率不掉、没有忽明忽暗的跳变、远处没有明显的 LOD 突变。达到这三条,再进交互。

4. 交互开发:射线、传送、抓取三件套的事件绑定

流程脚本第三段说「设计交互方式,编写交互逻辑,测试调试」,这是整份材料里最虚的一段,因为交互好不好用几乎全在细节。虚拟展厅的交互其实就三件事:指、走、拿。这一章把三件事落到具体组件和事件上。

4.1 三件套怎么选:别给观众三个手柄按键

先说结论:展厅类项目的交互要「少而明确」。观众戴着头显进来,没人愿意学操作。我的常规配置是:射线只做「指到展品出信息」,传送只做「地面点选瞬移」,抓取只对少量可把玩的展品开放。

射线用直线还是弧形?展厅空间规整,直线就够,弧形反而会让远处展品的选中点偏移。传送要限制落点:加一层地面碰撞层过滤,只允许传送到标记为可站立的地面,否则观众能传进墙里、传上展台,然后卡在那里。抓取要设远近两种,近处直接抓,远处用射线拉取,但拉取距离限制在 3 米内,超过就只显示信息不移动物体——观众把展品拽到半空再松手,掉地上找不到,是展厅里最常见的求助。

4.2 展品焦点事件:hover 和 select 挂在不同位置

这是新手最容易写错的地方。直觉是「点一下展品弹面板」,于是把逻辑写在 select 事件里。问题是抓取和点击共用 select,抓起来的同时弹面板,面板还会跟着手飞。正确拆分是:hover(射线停留)负责预览高亮和展品名,select(按下扳机)负责弹详情面板和播讲解音。

using UnityEngine; using UnityEngine.XR.Interaction.Toolkit; [RequireComponent(typeof(XRGrabInteractable))] public class ExhibitFocus : MonoBehaviour { [SerializeField] private GameObject infoCanvas; // 世界空间详情面板 [SerializeField] private AudioClip narration; // 讲解音频 private XRGrabInteractable _grab; private AudioSource _audio; void Awake() { _grab = GetComponent<XRGrabInteractable>(); // 讲解音走 2D,避免观众转头时声音忽左忽右 _audio = gameObject.AddComponent<AudioSource>(); _audio.playOnAwake = false; _audio.spatialBlend = 0f; _audio.clip = narration; // hover 只做轻量反馈,select 才做重操作 _grab.hoverEntered.AddListener(OnHoverEnter); _grab.hoverExited.AddListener(OnHoverExit); _grab.selectEntered.AddListener(OnSelect); } void OnHoverEnter(HoverEnterEventArgs args) { // 射线停留:只改描边和显示名牌,不弹面板 GetComponent<Renderer>().material.SetFloat("_Outline", 1f); } void OnHoverExit(HoverExitEventArgs args) { GetComponent<Renderer>().material.SetFloat("_Outline", 0f); } void OnSelect(SelectEnterEventArgs args) { // 按下扳机:弹面板 + 播讲解,面板朝向观众 if (infoCanvas != null) { infoCanvas.SetActive(true); var cam = Camera.main.transform; infoCanvas.transform.position = cam.position + cam.forward * 1.2f; infoCanvas.transform.rotation = Quaternion.LookRotation( infoCanvas.transform.position - cam.position); } if (!_audio.isPlaying) _audio.Play(); } }

几个关键点解释一下。spatialBlend = 0f是故意的:讲解音如果做成 3D 音效,观众一转头声音就从左耳跑到右耳,听感很怪;环境音才该用 3D。面板定位用「相机前方 1.2 米」而不是挂在展品上,是因为挂在展品上时观众得扭头找,1.2 米是舒适阅读距离,太近会有压迫感。Quaternion.LookRotation那行的参数顺序是「面板位置减相机位置」,得到从相机指向面板的方向,让面板正对观众。

提示:面板激活后记得在关闭时销毁或隐藏,别每点一次就 Instantiate 一个,半小时后场景里躺几百个面板,帧率会给你脸色看。

4.3 输入与真机模拟:编辑器里怎么把流程走通

编辑器里没有头显,测试交互靠 XR Device Simulator 这类模拟器:键盘控制头部朝向,鼠标模拟手柄射线。它能覆盖 80% 的逻辑验证——射线能不能打中、面板位置对不对、事件有没有重复触发。剩下 20% 必须上真机:手柄按键映射、传送时的晕动感、抓取的手感阈值。

真机上第一个要测的是按键映射。一体机左右手手柄的 Trigger 和 Grip 在不同 SDK 里语义不完全一致,抓取用 Grip 还是 Trigger 要看目标设备习惯,别照抄教程。第二个是晕动感,瞬移不会晕,平滑移动会,展厅项目一律用瞬移,别为了「沉浸感」上平滑移动——观众吐在展厅里,这项目就结束了。

交互这段的验收标准:不戴头显能走通全流程逻辑,戴上头显后三分钟内不需要人教就会用。做不到第二条,说明交互设计过复杂了,砍功能。

5. 避坑与排查:五条从编辑器正常到真机翻车的记录

编辑器里一切正常,打进真机就出问题,这是 VR 开发的日常。下面五条是我在展厅项目里反复遇到的,每条都按「现象 → 原因 → 解决」写,遇到同类问题可以直接对号。

5.1 构建成功但头显里一片黑

现象:构建日志没有任何报错,装上应用启动后是纯黑,连启动画面都没有。

原因通常有三个,按概率排序:一是 Build Settings 的场景清单为空或者只勾了空场景;二是 XR 插件管理里没有为目标平台勾选运行时,包能装但初始化时找不到 XR 后端,直接黑屏;三是图形 API 顺序把不兼容的那个排在了前面。

解决:先看场景清单,再看 XR 设置里对应平台的勾选项,最后在 Player Settings 里把图形 API 顺序调成 Vulkan 优先、GLES3 兜底。三次里两次是前两个原因,别一上来就怀疑显卡驱动。

5.2 帧率从 72 掉到 38,且越走越低

现象:刚进场景还行,走两步开始掉帧,走完整圈稳定在 38 左右,重启应用又恢复。

原因多半是运行时分配。常见写法是在 Update 里FindObjectOfType找面板、每帧 new 一个 List、或者每帧改材质属性触发材质实例化。材质实例化这条最隐蔽:renderer.material和renderer.sharedMaterial差一个词,前者每次访问都会复制一份材质,几百个物体就是几百份。

解决:所有引用在 Awake 里缓存,材质属性改动用 MaterialPropertyBlock,列表复用不 new。改完用 Profiler 的 GC Alloc 列看,稳定运行时应该是每帧 0B。

5.3 世界空间 UI 射线点不中

现象:面板看得见,射线也能打出去,但按钮怎么按都没反应。

原因:世界空间 Canvas 需要一个专门处理 XR 射线的射线检测组件(Graphic Raycaster 的 XR 版本),普通 Canvas 上的那套是给鼠标用的;另外 EventSystem 里如果还挂着旧的独立输入模块,XR 输入根本进不来。

解决:给 Canvas 加上 XR 专用的图形射线检测组件,EventSystem 换成 XR UI 输入模块,同时确认 Canvas 上有 Collider(很多人忘了这步,射线打在空气上)。三步缺一不可,改完立刻生效。

5.4 讲解音只有一边耳朵响,或者走到某个位置突然断

现象:音频播放在编辑器里正常,真机上偏一边,或者观众走到展台另一侧声音消失。

原因:spatialBlend设成了 1(全 3D)但 Audio Listener 挂在相机上,头部转动时声源相对位置剧烈变化;断流则多半是音频源挂在了带碰撞体的物体上,物体被物理系统推走或者禁用。

解决:讲解类音频统一 2D(spatialBlend = 0),环境音才用 3D 并设置合理的 min/max distance。音频源挂在不会被禁用的父节点上,别挂在可抓取物体本身。

5.5 模型材质发紫或者贴图全白

现象:导入时好好的,构建后模型变成紫红色,或者贴图变成纯白。

原因:紫红色是着色器找不到,通常是管线不匹配——素材是内置管线做的,工程用的是 URP;纯白是贴图打包时被剥离,常见于放在 Resources 之外又被代码动态加载的资源。

解决:导入时用「升级材质到当前管线」批量转换,转换后在编辑器里逐个目视检查一遍;动态加载的资源放进 Resources 或者用 Addressables 显式标记,别依赖隐式引用。

6. 发布前的验收:一份十二项清单和一个必做的性能采样

流程脚本最后两段写「构建、测试、发布、维护」,落到实操就是一份清单。我在每个展厅项目构建前都会走一遍下面这张表,漏一项就要多打一次包,而一次完整构建加装机测试,二十分钟起步。

检查项判定标准常错点
场景清单首个场景是启动场景勾了测试场景
XR 运行时目标平台已勾选只勾了编辑器
目标帧率等于设备刷新率忘记设,默认 30
垂直同步已关闭沿用模板设置
纹理压缩ASTC默认 ETC2,显存翻倍
光照贴图已烘焙且无实时主光忘了点烘焙
碰撞层传送只落可站立面能传进墙里
音频讲解 2D、环境 3D全设 3D,转头就偏
面板关闭后不残留实例越点越卡
包体200MB 以内素材没压缩
启动时长15 秒内出画面首屏加载资源过多
退出恢复退出后系统正常未释放 XR 会话

表格过完,还有一件事必须做:真机上跑一次带采样的完整走位。不是站着看帧率数字,是按下录制,从入口走到最远的展台,再原路返回,全程记录。因为 VR 的掉帧往往发生在特定位置——某个转角背后藏了三百个装饰件,或者某个展品的材质触发了实时反射。静态看帧率永远是 72,走起来才知道哪里塌。

采样的做法很土但有效:在场景里放一个每五秒打印一次当前帧率和相机坐标的调试组件,只打进测试包,正式包剔掉。拿到日志后把坐标和帧率对上,掉帧位置一目了然,比在 Profiler 里翻帧图快得多。

using UnityEngine; public class PerfSampler : MonoBehaviour { float _timer; void Update() { _timer += Time.unscaledDeltaTime; if (_timer < 5f) return; _timer = 0f; // 帧率 + 位置一起打,方便事后把掉帧点对到展厅坐标上 var p = Camera.main.transform.position; Debug.Log($"[采样] FPS={1f / Time.smoothDeltaTime:F1} " + $"POS=({p.x:F1},{p.y:F1},{p.z:F1})"); } }

这段只做一件事:把性能数据和时间、空间绑在一起。血泪经验是,只看平均帧率会骗人,平均 70 的包可能在某一段只有 40。走位采样跑三遍,三遍都在同一位置掉,那就是那个位置的资产问题,去查那里的面数、材质和实时灯光;三遍掉的位置不一样,多半是 GC,回去看分配。

从那以后我每次构建前都强制走一遍这张表和一次走位采样,宁可多花二十分钟,也不想在交付现场戴着调试线缆满头大汗地找原因。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询