去年,我们团队开发了一款面向工业场景的混合现实(MR)应用。用户戴上MR头显,可以在真实环境中看到虚拟的三维模型、操作指引和实时数据,用于设备维修、培训和巡检。

应用刚做出来的时候,功能是完整的,但性能很差:帧率只有30-45fps,偶尔还会掉到20fps以下,用户戴一会儿就会头晕恶心。场景加载需要十几秒,模型切换有明显的卡顿,多人协作的时候延迟很高。

这样的性能,根本无法交付给用户使用。MR应用对性能的要求比普通应用高得多,因为帧率低或者延迟高会直接导致用户晕动症,体验非常差。

于是,我们启动了一个性能优化专项,目标是把稳定帧率提升到72fps以上(MR头显的最低要求),场景加载时间降到3秒以内,多人协作延迟降到100ms以下。

经过两个月的努力,我们最终达到了目标:稳定帧率90fps,场景加载2.5秒,多人协作延迟80ms。用户体验有了质的飞跃。

这篇文章,我想分享一下这次MR工作流性能优化的实战经验,从性能分析到具体的优化手段,聊聊我们是怎么把MR应用从慢变快的。

MR应用的性能特点

在说优化之前,先说说MR应用的性能特点,以及为什么MR应用对性能要求这么高。

MR(混合现实)应用,是在VR(虚拟现实)和AR(增强现实)的基础上发展起来的。它把虚拟的三维内容叠加到真实环境中,用户可以和虚拟内容进行交互。

MR应用的性能挑战,主要来自以下几个方面:

第一,渲染压力大。MR应用需要同时渲染真实环境(透视视频)和虚拟三维内容,而且需要双目渲染(左右眼各渲染一次),渲染量是普通应用的两倍。而且,为了保证透视效果,渲染分辨率通常很高(单眼1440x1600以上)。

第二,帧率要求高。MR头显的刷新率通常是72Hz、90Hz甚至120Hz。为了避免晕动症,应用的帧率必须稳定达到头显的刷新率,不能掉帧。普通应用掉到30fps用户可能还能接受,但MR应用掉到60fps以下,用户就会明显感到头晕。

第三,延迟要求低。从用户头部转动到画面更新,整个链路的延迟必须控制在20ms以内。延迟高了,用户会感到明显的眩晕。这就要求渲染、追踪、显示整个链路都要高效。

第四,空间计算复杂。MR应用需要实时理解周围环境,进行平面检测、空间映射、物体识别等计算。这些计算非常消耗CPU和GPU资源。

第五,交互实时性要求高。用户的手势、语音、控制器输入,需要实时响应。延迟高了,交互体验会很差。

第六,硬件资源有限。MR头显的硬件性能通常比手机强,但比PC差很多。CPU、GPU、内存、散热都有限制,不能像PC端那样无限堆性能。

了解了这些特点,我们就能明白为什么MR应用的性能优化这么重要,也这么有挑战性。

第一步:性能分析,找到瓶颈

优化的第一步,不是急着改代码,而是做性能分析,找到真正的瓶颈。

我们使用了多种性能分析工具:

  • 头显自带的性能分析工具,实时监控帧率、CPU使用率、GPU使用率、内存占用、温度等。
  • RenderDoc,抓取渲染帧,分析每个Draw Call的耗时、Shader的复杂度、纹理的使用等。
  • Unity Profiler(我们用的是Unity开发),分析CPU的耗时分布,包括脚本、渲染、物理、GC等。
  • 自研的性能埋点,在关键流程中添加时间统计,精确测量每个环节的耗时。

经过一周的性能分析,我们找到了主要的瓶颈:

第一,渲染瓶颈。GPU使用率长期在90%以上,主要消耗在:

  • 虚拟模型的三角面数太多,单个模型就有几百万面,场景中同时有十几个模型,总面数超过千万。
  • 材质和Shader太复杂,使用了PBR材质、法线贴图、高度贴图、光照探针等,计算量很大。
  • 透明物体和后处理效果( Bloom、抗锯齿、景深)消耗了大量GPU资源。
  • 没有做有效的遮挡剔除,很多被遮挡的物体也在渲染。

第二,CPU瓶颈。CPU使用率也很高,主要消耗在:

  • 场景加载时,模型解析和实例化耗时很长。
  • 物理引擎的计算量太大,场景中有很多刚体和碰撞体。
  • C#脚本的逻辑复杂,每帧都有大量的计算和遍历。
  • GC(垃圾回收)频繁,每几秒就触发一次,造成明显的卡顿。

第三,内存瓶颈。内存占用超过了2GB,接近头显的内存上限。主要是:

  • 模型和纹理的资源太大,没有做有效的压缩。
  • 场景切换时,旧资源没有及时释放,造成内存泄漏。
  • 没有做资源池,频繁创建和销毁对象。

第四,网络瓶颈。多人协作时,网络延迟很高,主要是:

  • 同步的数据量太大,每帧都同步所有物体的变换。
  • 没有做数据压缩,网络传输量大。
  • 同步策略不合理,重要数据和不重要数据用同样的频率同步。

找到这些瓶颈之后,我们就可以针对性地进行优化了。

渲染优化:从千万面到百万面

渲染是我们最大的瓶颈,所以我们首先从渲染开始优化。

第一,模型减面。这是效果最明显的优化。我们对所有的三维模型进行了减面处理:

  • 使用减面工具(比如Simplygon、Blender的Decimate),在不影响视觉效果的前提下,把模型的三角面数减少70%-90%。
  • 对于远处的模型,使用LOD(Level of Detail)技术,远距离时用低面数模型,近距离时才用高面数模型。
  • 对于内部结构、不可见的面,直接删除。很多工业模型有大量的内部结构,在MR场景中根本看不到,完全可以删掉。
  • 合并相同材质的网格,减少Draw Call的数量。

通过这些处理,我们把场景的总面数从原来的1000多万面,降到了100多万面,减少了90%。渲染帧率立刻提升了20多fps。

第二,材质和Shader优化。我们对材质和Shader进行了简化:

  • 去掉了不必要的纹理贴图,比如高度贴图、AO贴图,只用基础的Albedo、Normal、Metallic贴图。
  • 对纹理进行压缩,使用ASTC压缩格式,纹理内存减少了50%-75%。
  • 简化Shader,去掉了不必要的计算,比如复杂的光照模型、次表面散射等。
  • 合并材质,尽量使用共享材质,减少材质切换的开销。
  • 对于不重要的物体,使用更简单的Shader,比如Unlit或简单的Lambert。

第三,遮挡剔除和视锥剔除。我们启用了有效的遮挡剔除:

  • 使用Occlusion Culling,被其他物体遮挡的物体不渲染。
  • 确保视锥剔除(Frustum Culling)正常工作,不在视野内的物体不渲染。
  • 合理设置物体的包围盒,避免因为包围盒太大导致剔除失效。

第四,后处理优化。我们简化或去掉了一些后处理效果:

  • 去掉了景深效果,MR应用中景深不仅没用,还会影响透视效果。
  • 简化了Bloom效果,降低了分辨率和迭代次数。
  • 使用了更高效的抗锯齿方案,比如MSAA而不是后处理抗锯齿。
  • 确保后处理只在必要时启用,不要一直开着。

第五,渲染管线优化。我们对渲染管线进行了优化:

  • 使用了单通道立体渲染(Single Pass Stereo Rendering),左右眼只渲染一次,减少了一半的Draw Call和CPU开销。
  • 启用了GPU实例化(GPU Instancing),相同的物体批量渲染。
  • 合理设置渲染分辨率,在保证清晰度的前提下,适当降低渲染分辨率,减轻GPU压力。
  • 使用了固定注视点渲染(Foveated Rendering),视野中心区域高分辨率渲染,边缘区域低分辨率渲染,减少了渲染量。

通过这些渲染优化,我们的GPU使用率从90%以上降到了60%左右,帧率提升了30多fps。

CPU优化:从卡顿到流畅

解决了渲染瓶颈之后,我们开始优化CPU。

第一,场景加载优化。原来场景加载需要十几秒,我们做了以下优化:

  • 把模型转换成引擎原生格式,避免运行时解析。
  • 使用异步加载,在加载场景的同时显示加载界面,不阻塞主线程。
  • 对大场景进行分块,按需加载,只加载用户视野内的内容。
  • 使用对象池,预创建常用的对象,避免运行时频繁创建和销毁。
  • 压缩资源,减少下载和读取时间。

通过这些优化,场景加载时间从十几秒降到了2.5秒。

第二,物理优化。物理引擎的计算消耗了很多CPU,我们做了以下优化:

  • 减少刚体和碰撞体的数量,只对需要交互的物体添加刚体和碰撞体。
  • 简化碰撞体,尽量使用简单的碰撞体(盒子、球体、胶囊),而不是网格碰撞体。
  • 调整物理更新频率,不需要每帧都更新物理,可以降低到30Hz或更低。
  • 禁用不必要的物理特性,比如连续碰撞检测、关节等。
  • 对远处的物体禁用物理模拟。

第三,脚本逻辑优化。我们对C#脚本进行了全面的优化:

  • 减少每帧的计算量,把不需要每帧更新的逻辑改成定时更新或事件驱动。
  • 避免在Update中做复杂的计算和遍历,把复杂计算放到后台线程或协程中。
  • 优化数据结构,使用数组和List代替Dictionary和LinkedList,减少内存分配和遍历开销。
  • 缓存常用的组件引用,避免频繁使用GetComponent。
  • 避免字符串拼接和装箱拆箱,减少GC压力。

第四,GC优化。GC频繁是造成卡顿的重要原因,我们做了以下优化:

  • 使用对象池,避免频繁创建和销毁对象。
  • 避免在循环中创建临时对象。
  • 使用结构体代替类,减少堆内存分配。
  • 避免使用foreach(在旧版本Unity中会产生GC),改用for循环。
  • 对字符串操作使用StringBuilder,避免频繁创建字符串。
  • 手动控制GC时机,在加载场景或不影响体验的时候触发GC。

通过这些CPU优化,我们的CPU使用率从80%多降到了50%左右,帧率又提升了10多fps,而且卡顿明显减少。

内存优化:从2GB到800MB

内存占用过高也是一个大问题,我们做了以下优化:

第一,资源压缩。我们对所有的资源进行了压缩:

  • 纹理使用ASTC压缩格式,根据纹理的重要性选择不同的压缩质量。
  • 模型使用压缩格式,减少网格数据的大小。
  • 音频使用压缩格式,降低采样率和比特率。
  • 动画使用压缩,减少关键帧数量。

第二,资源管理。我们建立了完善的资源管理机制:

  • 场景切换时,及时卸载不再使用的资源,释放内存。
  • 使用引用计数,确保资源在不使用时被正确释放。
  • 对常用资源进行缓存,避免重复加载。
  • 建立内存监控,实时监控内存使用情况,发现泄漏及时修复。

第三,对象池。我们广泛使用了对象池:

  • 对频繁创建和销毁的对象(比如特效、UI元素、临时物体)使用对象池。
  • 对象池中的对象在不使用时禁用,而不是销毁,需要时再启用。
  • 合理设置对象池的大小,避免占用过多内存。

通过这些内存优化,我们的内存占用从2GB降到了800MB左右,远离了内存上限,应用也更稳定了。

网络优化:从500ms到80ms

多人协作的网络延迟也是一个大问题,我们做了以下优化:

第一,减少同步数据量。我们大幅减少了需要同步的数据:

  • 只同步必要的物体,不同步不重要的装饰性物体。
  • 只同步变化的数据,没有变化的物体不同步。
  • 对数据进行量化,比如位置用整数表示,旋转用压缩格式,减少数据大小。
  • 使用增量同步,只同步变化的部分,而不是全量同步。

第二,数据压缩。我们对网络数据进行了压缩:

  • 使用高效的序列化格式,比如Protobuf或MessagePack,而不是JSON。
  • 对数据包进行压缩,比如使用gzip或LZ4。
  • 合并多个小的数据包,减少网络开销。

第三,同步策略优化。我们优化了同步策略:

  • 对重要的数据(比如用户的头部和手部位置)用高频率同步(90Hz)。
  • 对不重要的数据(比如远处的物体、环境变化)用低频率同步(10Hz或更低)。
  • 使用预测和插值,在网络延迟的情况下也能保持流畅的体验。
  • 对不重要的变化进行节流,避免频繁同步。

第四,网络架构优化。我们优化了网络架构:

  • 使用了更高效的网络库,比如Photon或Mirror。
  • 选择了更近的服务器,减少物理延迟。
  • 使用了UDP而不是TCP,减少延迟和重传开销。
  • 建立了网络状态监控,实时监控延迟和丢包率。

通过这些网络优化,多人协作的延迟从原来的500ms降到了80ms,协作体验流畅了很多。

其他优化:细节决定成败

除了上面这些大的优化方向,我们还做了很多细节优化:

第一,输入延迟优化。MR应用对输入延迟非常敏感,我们做了以下优化:

  • 减少输入处理的延迟,确保输入在第一帧就被处理。
  • 使用了应用空间扭曲(Application SpaceWarp),在帧率不足时也能保持低延迟。
  • 优化了手势识别和追踪的算法,减少计算延迟。

第二,散热优化。MR头显的散热有限,温度高了会降频,导致性能下降。我们做了以下优化:

  • 监控设备温度,在温度过高时适当降低性能,避免降频。
  • 优化了功耗,减少不必要的计算,降低发热。
  • 提醒用户在通风良好的环境中使用,避免设备过热。

第三,电池优化。MR头显的电池续航有限,我们做了以下优化:

  • 降低了平均功耗,延长了使用时间。
  • 在不使用时自动进入低功耗模式。
  • 提供了性能模式和省电模式,用户可以根据需要选择。

第四,UI优化。UI的性能也很重要,我们做了以下优化:

  • 使用了更高效的UI方案,减少Draw Call和过度绘制。
  • 避免了复杂的UI动画,减少GPU开销。
  • 对UI进行了分层,重要的UI优先渲染。
  • 确保UI文字清晰可读,避免因为分辨率问题导致模糊。

这些细节优化虽然单个效果不明显,但叠加起来,对整体性能和体验的提升也很大。

优化成果:从慢到快

经过两个月的优化,我们取得了显著的成果:

  • 稳定帧率:从30-45fps提升到90fps,而且非常稳定,很少掉帧。
  • 场景加载时间:从十几秒降到2.5秒。
  • 多人协作延迟:从500ms降到80ms。
  • 内存占用:从2GB降到800MB。
  • CPU使用率:从80%多降到50%左右。
  • GPU使用率:从90%以上降到60%左右。
  • 设备温度:明显降低,不再频繁降频。
  • 电池续航:提升了30%左右。

用户体验有了质的飞跃。用户戴上头显后,不再感到头晕恶心,场景加载很快,交互流畅,多人协作体验良好。我们的应用终于达到了可以交付的水平。

更重要的是,我们建立了一套完善的性能优化流程和规范,以后开发新功能时,会从一开始就考虑性能,避免再出现性能问题。

经验总结:MR性能优化的关键点

这次性能优化,给了我们很多经验和教训。总结一下MR工作流性能优化的关键点:

第一,性能分析先行。优化之前一定要做详细的性能分析,找到真正的瓶颈。不要凭感觉优化,很多时候你以为的瓶颈并不是真正的瓶颈。用数据说话,针对性地优化。

第二,渲染是重中之重。MR应用的渲染压力是普通应用的两倍,渲染通常是最大的瓶颈。模型减面、材质简化、遮挡剔除、渲染管线优化,这些是最有效的优化手段。

第三,CPU和内存不能忽视。渲染优化之后,CPU和内存可能成为新的瓶颈。场景加载、物理计算、脚本逻辑、GC、内存管理,这些都需要优化。

第四,网络延迟影响协作体验。多人协作是MR应用的重要场景,网络延迟直接影响协作体验。减少数据量、数据压缩、优化同步策略、选择高效的网络架构,这些都很重要。

第五,细节决定成败。除了大的优化方向,很多细节优化(输入延迟、散热、电池、UI)也会影响整体体验。不要忽视这些细节。

第六,建立性能规范。优化不是一次性的,而是持续的。要建立性能规范,在开发过程中就关注性能,定期做性能测试,避免性能问题积累。

第七,用户体验是最终目标。所有的优化,最终都是为了用户体验。帧率、延迟、加载时间,这些指标最终都要转化为用户的实际感受。不要为了指标而优化,要为了用户体验而优化。

写在最后

MR应用的性能优化,是一个系统工程,涉及渲染、CPU、内存、网络、交互等多个方面。它没有银弹,需要耐心地分析、细致地优化、持续地迭代。

但当你看到优化后的应用流畅运行,用户戴上头显后不再头晕,而是惊叹于混合现实的神奇时,你会觉得所有的努力都是值得的。

MR技术正在快速发展,硬件性能在不断提升,开发工具在不断完善。但无论硬件怎么发展,性能优化永远是重要的。因为用户对体验的要求也在不断提高,永远不会满足于"能用",而是追求"好用""流畅""沉浸"。

希望我们的这次性能优化实战,能给正在开发MR应用的朋友一些参考。愿大家都能做出流畅、沉浸、令人惊叹的MR应用。

性能优化之路,永无止境。与诸君共勉。