ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Unity移动VR性能优化:GPU实例化与内存管理实战,稳定90Hz

2026/10/2 4:58:09 拓冰建站 浏览量
Unity移动VR性能优化:GPU实例化与内存管理实战,稳定90Hz 写到第五篇我自己都能感觉到整个项目已经从一个“能不能把这么大的村庄放进去”的质疑变成一个“怎么让它每分每秒都保持流畅”的打磨过程。前四篇里我先后处理了风格化资源的选型和面数控制把 Unity 的渲染管线切到适合移动端的配置又对村庄中的大型建筑做了网格减面和 LOD 分级。到了这一轮场景已经能跑但是如果你在 PICO Neo3 里真的走上一圈还是会遇到明显的卡顿、偶尔的掉帧以及角落里一些小物件的加载延迟。这第五篇我主要想解决三个更底层的问题看不见的东西尽量不画重复的东西尽量合并不该占的内存尽量别占。这几件事看起来没前面几篇那么刺激但恰恰是它们决定了你最后是“能进村”还是“能在村里随便跑”。1. 第五轮优化从哪入手先给帧时间算一笔总账1.1 不要凭感觉优化先把瓶颈分清楚很多朋友在优化 VR 项目时习惯一上来就开 Profiler看到 CPU 和 GPU 时间哪个高就去改哪里。这个思路没有错但容易漏掉一个前提你要优化的对象是 XR 一体机不是 PC 端的普通游戏。PICO Neo3 用的是骁龙 XR2 平台Adreno 650 这颗 GPU 在同级设备里不算弱但它的渲染压力远比普通手机厉害因为同一帧画面要渲染两只眼睛也就是两遍。再加上 Neo3 的单眼分辨率是 1832×1920刷新率又是 90Hz留给一帧的时间大约只有 11.1 毫秒。在做这一轮优化前我先给自己定了一个清单把上一轮剩下的问题全部量化出来而不是笼统地感觉“有点卡”。当时我记录到的主要问题是场景整体 Draw Call 在每只眼 400 上下CPU 端的渲染线程经常因为批处理拆分而飙高三角形面数虽然已经从最初的 50 万压到 25 万左右但村里那些小石头、栅栏、树木碎片的重复网格没有合并单帧渲染批次很碎内存峰值在加载完整场景时接近 1.4GB虽然 Neo3 有 6GB 内存但系统和 Unity 本身已经吃掉了相当一部分留给场景的余量并不宽裕帧时间不稳定平均值看着有 18ms但 99 分位经常冲到 27ms也就是玩家走着走着突然顿一下。这些数据放在一起你就能看出一个方向这一轮优化的重点不在单个模型有多精细而在“重复的东西太多、被渲染的东西太多、常驻内存的东西太多”。所以我给自己这一轮定的目标也很朴素把 Draw Call 压到每只眼 100 以内三角形继续砍掉一半内存峰值控制在 800MB 附近最终让 99 分位帧时间稳定在 13ms 以内。1.2 先固定测试路线优化才有可比性优化最怕的是今天测 A 路线、明天测 B 路线最后改了半天也不知道效果到底是多少。我这一篇里所有数据都是按照同一条固定的测试路线跑出来的从村庄入口出发穿过中央广场走到磨坊区再绕到后山的梯田最后原路返回。这条路线涵盖了开阔地形、密集建筑区、植被混杂区和半遮挡场景基本能把压力最大的情况都覆盖到。记录工具我用的是 Unity Profiler 加 Android 侧的 gfxinfo。如果你用的是 Neo3 这类设备可以在 adb 里直接看每帧的渲染信息命令大概是adb shell dumpsys gfxinfo com.你的包名 reset它会输出一段从 reset 时刻开始统计的帧时间分布重点看 90 分位和 99 分位而不是只看均值。因为对于 VR 来说一次明显的掉帧比持续的低一点更影响体验可能会让人瞬间产生眩晕感这个是任何帧率平均值都掩盖不了的问题。2. 重复物件处理GPU Instancing 才是村落的救兵2.1 Static Batching 为什么在这个项目里不好用风格化村庄里最多的东西是什么不是房子也不是道路而是树、石头、栅栏、木桶、稻草堆这些小物件。我最初为了减少 Draw Call把很多相同材质的物体放进 Unity 的 Static Batching效果确实有一些但很快就发现了两件事。第一Static Batching 会把大量网格合并成一个大 VBO内存开销非常高。一个村庄里可能有几百棵风格化树木如果全部静态批处理合并GPU 内存就直接爆掉了而且一旦某个物体需要动态变化这个批处理就要重建。第二Neo3 上很多物体是玩法上与玩家有交互的食物比如可以被风吹动的树叶不能设成静态的强制合并不是不行但后续改起来非常痛苦。所以第五轮我换成了 GPU Instancing也就是让同一个网格、同一个材质在一次 Draw Call 里画几百个实例。这个思路的好处在于不会把顶点数据复制成好几份内存相对可控而且实例的矩阵可以动态更新适合大量相似物体在场景中自由摆放的场景。2.2 用 DrawMeshInstanced 把几百棵灌木一次画完我处理得最多的是村庄外围的灌木和矮树。这些物体面数不高但数量巨大。最初它们都是独立的 GameObject每个都带自己的 RendererDraw Call 一下子就被撑爆了。后来我改成按网格分组的实例化方案把所有相同网格的灌木收集起来用Graphics.DrawMeshInstanced每帧画出去。这里有一个关键点DrawMeshInstanced单次调用最多支持 1023 个实例超过这个数就要拆成多次。所以我每个类型的灌木分一个组每组控制在 800 个以内留出余量避免随着场景微调超过限制。大致的调用逻辑如下using UnityEngine; public class BushInstancer : MonoBehaviour { public Mesh bushMesh; public Material bushMaterial; public Transform player; private Matrix4x4[] matrices new Matrix4x4[800]; void Start() { // 从你场景的灌木列表中填充矩阵数组 // 每个元素 Matrix4x4.TRS(pos, rotation, scale) } void Update() { // 这里按玩家距离做简单排序远的先剔除 // 再把剩余实例传给 GPU 绘制 Graphics.DrawMeshInstanced(bushMesh, 0, bushMaterial, matrices, visibleCount); } }用这种方式之后2000 多棵灌木原本要产生 2000 个 Draw Call现在只需要 3 到 4 个差距是毁灭性的。但这里有个隐藏陷阱如果你对灌木使用了烘焙光照贴图Unity 默认会认为每个实例可能对应不同的 lightmap index从而打断实例化。我在排查时发现某几棵树的批处理始终合不起来后来才意识到是它们被烘到了不同的光照图页面上。解决办法是把同一类物体的 UV 展开到同一张光照贴图区域确保它们共享同一个 lightmap index。2.3 实例化之后的关键检查材质一定要开 Enable Instancing这一点听起来很基础但我第一次搞的时候确实被坑了。一个材质如果没勾选 Enable Instancing即使你写了 DrawMeshInstancedUnity 也只会把它当作普通绘制来跑Draw Call 不会有任何下降。所以我把场景里所有用于 GPU Instancing 的材质都检查了一遍包括默认的 URP Lit 材质和风格化专用 Shader。另外不同实例如果需要不同颜色比如灌木的深浅绿差异不要给每个实例复制材质不然又会拆掉批处理。正确做法是给它们换成 Uniform 的 Instance color并用MaterialPropertyBlock分配。不过在风格化美术里颜色差异也可以通过不同的纹理小贴图实现这边就不再展开后面第四部分会详细说。3. 剔除与分区加载看不见的物体本来就是负担3.1 别把视锥剔除想得太万能很多时候我们看到 Unity 里默认开着 Camera 的 Culling Mask就默认在场景中摆动视角时屏幕外的物体不会绘制。实际上视锥剔除只能排除真正完全在相机可视角范围之外的物体。村庄场景最大的问题是视野开阔在 Neo3 这种广角 VR 设备上屏占比极高一眼望过去你几乎能看到大半个村子的房屋和山体。那些被房屋遮住的小路、篱笆、水井虽然看不见却仍然在渲染队列里占位置。所以我做的第一层剔除是针对所有小型物体的“距离剔除”。因为风格化村庄里有很多像水罐、木柴、南瓜这样的装饰物当它们离玩家超过 15 米时其实已经只是一团色彩根本看不出细节。与其让它们继续占用 Draw Call不如直接关掉 Renderer。我写了一个非常轻量的距离控制组件挂在所有小物件的父节点上using UnityEngine; public class DistanceCullGroup : MonoBehaviour { public Transform target; // 玩家相机 public float showDistance 12f; public float hideDistance 16f; // 与显示距离形成滞回区间 private Renderer[] renderers; private bool isVisible true; void Start() { renderers GetComponentsInChildrenRenderer(true); } void Update() { float dist Vector3.Distance(target.position, transform.position); bool shouldShow isVisible ? dist hideDistance : dist showDistance; if (shouldShow ! isVisible) { isVisible shouldShow; for (int i 0; i renderers.Length; i) { renderers[i].enabled isVisible; } } } }注意我用了一对“显示距离”和“隐藏距离”构成一个滞回区间防止玩家在临界位置走来走去时物体会连续开关闪烁。这个小系统给 CPU 和渲染线程省下了一大笔开销因为场景里大量的小物件都被有效地“物理销户”了。3.2 遮挡剔除在村庄场景里的善用与放弃做过 PC 端朋友一定会想到遮挡剔除。但说实话遮挡剔除在移动 VR 平台上尤其是在风格化村庄这种场景中性价比并不高。原因是 PICO Neo3 的 CPU 并不算强每次做遮挡查询本身要耗费时间村庄里又到处都是树冠和矮墙遮挡体系很碎Bake 出来的遮挡数据又大又难以控制最后反而可能拖慢帧率。我的处理策略是不它对整个场景做而是手工指定几个大型遮挡物。比如村庄背后的大山、磨坊建筑、广场中央的大树这些物体的体积足够大适合作为 Occluder。通过这种小范围的手动遮挡剔除我能把山体背面房屋的绘制节省掉同时付出的 CPU 代价几乎可以忽略。如果你也打算这样做建议在 Unity 的 Occlusion Culling 窗口里只勾选少量体积正确的对象然后在 Baking 时选择 Small 内存模式实测效果会稳定得多。3.3 轻量级分区加载从根本减少常驻内存前四篇我一直在说场景的网格和贴图大头但内存的另一个大头是“整个场景都常驻在内存里”。村庄里有很多玩家不一定进去的角落比如后山梯田、河边小码头这些区域虽然没有渲染出来但它的网格、贴图、材质数据全部都躺在内存里。要解决这个问题我一开始考虑过 Addressables但后来觉得对这么小规模的项目来说它的配置和依赖分析反而复杂。我最后采用了最直接的 Sector 分区加载方案。把村庄按地理位置分成几个区域每个区域的根节点挂一个 SectorLoader。玩家进入一定范围后根节点跨几帧激活离开一定范围后再停用。核心逻辑大致这样using System.Collections; using UnityEngine; public class SectorLoader : MonoBehaviour { public Transform player; public float loadRadius 25f; public float unloadRadius 40f; public GameObject sectorRoot; private bool loaded; void Update() { if (sectorRoot null || player null) return; float dist Vector3.Distance(player.position, transform.position); if (!loaded dist loadRadius) { StartCoroutine(ActivateSector(true)); } else if (loaded dist unloadRadius) { StartCoroutine(ActivateSector(false)); } } IEnumerator ActivateSector(bool on) { yield return null; sectorRoot.SetActive(on); loaded on; } }这个方案让场景的内存峰值下降了大约 25%代价是跨区时偶尔会出现一瞬间的小物体会晚半拍出现。为了不穿帮我特意把边界都设置在玩家视线被房屋或山体遮挡的位置这样玩家实际上很难注意到加载过程。这个思路对独立开发者尤其友好因为不用引入复杂的资源管理框架夜里睡不着也能写完。4. 纹理、光照与内存预算把每一兆都花在该花的地方4.1 ASTC 压缩不是无脑选越狠越好PICO Neo3 搭载的 Adreno GPU 对 ASTC 格式支持非常成熟这是移动端 VR 的福音。但在做纹理压缩时一定要分清“减小体积”和“压缩到能看”的区别。这个项目最开始时我为了追求低内存把很多风格化贴图一股脑压到 ASTC 8×8。结果近看时屋顶瓦片的渐变色块出现很明显的色斑本来就靠色块堆叠的风格化画面一下变得脏兮兮的。后来我按贴图的重要性分了级大面积的地面、草地、远山背景用 ASTC 8×8因为这些内容高速移动时玩家注意不到细节建筑外墙、道路石板这类离玩家较近的贴图用 ASTC 6×6而角色、重要道具、交互物品保留 ASTC 4×4。这样分配后内存再次下降同时视觉上几乎看不出劣化。我这里整理了一张我当时用的策略表你也可以直接参考贴图类型压缩格式备注天空盒、远山、地面草皮ASTC 8×8大块低细节占内存主力墙壁、屋顶、栅栏ASTC 6×6风格化色块能接受轻微斑块道具、交互物品、玩家可近看的物件ASTC 4×4保证边缘和笔触清晰UI、加载图、文字贴图不压缩或 ASTC 4×4避免文字发糊4.2 光照贴图合图与实例化的恩怨第五轮我继续优化了烘焙光照。风格化村庄的画面有很大一部分靠烘焙光影撑起来因为动态实时光太费了。但烘焙光照和 GPU Instancing 之间存在一个很微妙的冲突如果一个渲染器引用的 lightmap index 和它旁边同网格的实例不同Unity 就没有办法把这两个实例合并成一次绘制。我最终把整个村庄的静态物体尽量集中烘到三到四张 1024 或 2048 的光照贴图内并单独设置 UV 缝隙和缩放。这样做相当于把所有静态物体尽量归并到相同的 lightmap 页确保实例化能生效。这里有个经验不要为了光影细节去烘十几张小图移动端光照贴图要粗而集中宁可损失一点点间接光层次也要保住批处理不被打断。4.3 后处理与渲染比例的取舍很多人在 VR 项目里喜欢开各种后处理效果比如泛光、景深、环境光遮蔽。但从我实测的结果看这些都是 Neo3 帧时间的吞噬者尤其景深和抗锯齿对 GPU 的压力几乎呈指数上升。为了保持风格化的氛围我最后只保留了一个非常轻量的色彩校正也就是把后处理链砍到只剩 Lift/Gamma/Gain 和微弱的 Vignette用来统一整体色调。其它什么体积光、大气散射、动态模糊全部干掉风格化村庄的调性本身依靠材质和光线方向就能表达不需要那些重特效。渲染比例也是个大头。Neo3 的画面清晰度在默认情况下已经不错但如果 GPU 压力大完全可以先用 0.85 的渲染倍率保帧率然后再看是否需要恢复。我最后是运行时根据帧时间动态调整XRSettings.renderScale在 0.9 到 1.0 之间来回浮动。这样平时保证清晰度在密集场景压力变大时自动降一点玩家基本感知不到清晰度的变化但帧率稳稳的。5. 实测帧时间与稳定性优化的成果要落到帧率上5.1 从 18ms 到 11.8ms 的完整过程这一轮改完以后我又沿着那条固定路线跑了三遍记录下 CPU、渲染线程和 GPU 的帧时间。拿改之前和改之后的数据对比结论一目了然指标优化前优化后变化CPU 主线程帧时间18.6 ms9.8 ms减少 47%渲染线程帧时间14.5 ms7.6 ms减少 47%GPU 帧时间18.2 ms11.6 ms减少 36%每眼 Draw Call41288减少 78%每眼三角形数量212K96K减少 54%内存峰值1380 MB806 MB减少 41%99 分位帧时间27ms13.4ms减少 50%能看到主要收益来自大批量的小物件实例化、静态物体集中光照图、以及距离剔除。值得一提的是三角形数量比预想中下降得还要多一点因为我在距离剔除时把远距离的小石头和栅栏整组关掉了这些物体的面数其实很可观。现在的平均帧时间已经低于 11.1ms意味着 90Hz 下大部分时间是稳的。但真正让我放心的是 99 分位从 27ms 降到了 13.4ms几乎不会再出现突然顿一下的情况了。在 VR 里这个“突然顿一下”才是影响舒适度的关键。5.2 设备发热后的降频问题动态分辨率是保命手段Neo3 这类一体机还有一个 PC 游戏完全不用考虑的问题发热降频。你刚开机时帧率很好玩二十多分钟以后机器温度上来CPU 和 GPU 频率会被系统压低帧时间会慢慢回升。这是硬件散热决定的不是软件能彻底解决的但我们可以通过动态分辨率来给设备“减压”。我写了一个很简单的脚本根据上一帧的耗时来调整渲染倍率。如果帧时间超过 12ms就把 renderScale 降低一档如果帧时间很充裕就慢慢把 scale 加回去。刚运行时变化会很激进所以要设置区间和步长避免清晰度肉眼可见。5.3 用 PICO Neo3 的性能模式榨出最后一点余量后来我还发现PICO 的 XR SDK 里提供了性能等级切换包括普通模式和性能模式。把设备固定在高性能档位上可以让系统在散热允许的范围内尽可能维持高频率。但是别无脑一直开因为长时间跑在这个档位会让温度迅速升高反而触发更严格的降频。我的做法是在进入村庄中心高密度区域前手动拉高在开阔区域又把性能档降回去。虽然麻烦了一点但换来了更平滑的长时间游玩体验。6. 这一轮踩过的坑列出来帮你避6.1 自发光材质把实例化毁了一个小时我为了让村庄夜景更出效果给不少灯笼和窗户加了自发光材质。当时为了动态控制灯光的亮灭我使用了两套材质。结果这些物体完全无法和其他实例合批Draw Call 一下子涨回去不少。排查了很久才发现不是脚本问题而是材质变体拆批了。后来我把动态亮灭改成通过 Instance 的 float 参数去控制不再切换材质这个问题才解决。6.2 烘焙光照后草地的明暗过渡突然变脏风格化讲究大色块和明确的明暗方向但光照贴图如果用太高精度的贴图反而会把模型法线里的细微起伏放大导致草地看起来像一块块的补丁。我后来在材质里把法线贴图的影响降到很低光照贴图和模型法线之间的“锋利感”一下子就出来了整体观感反而更接近概念图的效果。6.3 动态分辨率别调太细我一开始把 renderScale 的调整步长设得很小每帧都调。结果发现虽然帧率稳了但画面清晰度在持续波动玩家看起来反而有一种难以言说的不舒服。后来我把检测间隔改到每半秒一次并且只允许 0.9、0.95、1.0 三档情况才稳定下来。VR 里宁可模糊稳定也不要忽高忽低的清晰度变化。6.4 不要让 Sector 加载太激进分区加载的触发距离如果设得太近玩家走路稍快一点就会发现前面一个区域的物体还没出现。如果设得太远又达不到内存节省的效果。我最终的平衡点是加载半径 25 米卸载半径 40 米并且所有边界都放在房屋或山体遮挡的位置。这样玩家几乎是先看到区域内的地标才会注意到装饰物逐步出现穿帮概率很低。第五轮做完之后这个风格化村庄已经能够在 PICO Neo3 上相对舒服地逛起来了。无论是视觉上的风格感还是长时间游玩的帧率稳定性都达到了我当初预期的水平。如果你也在做移动 VR 里的开放或半开放场景建议你不要只盯着单个模型的面数和贴图大小而是把注意力放在“排除看不见的东西、合并重复的东西、管理好内存的常驻范围”这三件事上。它们看起来不如一个炫酷的 Shader 有存在感却是 VR 流畅体验的真正基石。