游戏优化到底从哪里开始?不少人第一反应是打开 Profiler 看一眼,然后被几百条调用栈和密密麻麻的耗时数据淹没,最后什么都没改就关掉了。换个思路提一个问题:如果场景里只有 3 万棵树,帧率就已经掉到十几帧,你会怎么定位问题?这听起来像是“资产太多”导致的,但真正的原因可能是 Draw Call 爆炸、材质 Pass 过多、阴影重复渲染,甚至只是相机裁剪距离设置不合理。这次的实战主题就是“渲染 3 万棵树”,用这个足够典型的大规模场景,把一套能复用到任何项目的性能诊断与优化方法论讲清楚。
这套方法论不针对某个特定引擎,也不依赖某个高级插件。它的核心动作只有三步:先稳定复现,再量化拆解,最后单变量验证。整个过程更像是一套可执行的工作流,而不是零散的优化技巧。学会之后,不管你是优化一棵树还是优化一整个开放世界,思路都是同一个。特别是那些在项目里卡了好几天“不知道哪里卡”的朋友,这篇文章可以直接照着做。
我会从核心能力速览开始,把优化对象、瓶颈定位手段、工具链、验证方法都列出来,然后进入环境准备,给出一套通用的测试场景搭建方法。接着以 3 万棵树为例,逐项拆解 Draw Call、合批、LOD、遮挡剔除、材质复杂度、阴影和纹理采样这些最常见的开销来源。每拆一个点,都会告诉你“到底怎么测”“怎么看数据”“改完怎么验证”。最后会把整套流程脚本化,用 Python 做批量性能采样和对比分析,让你从“凭感觉调参数”变成“用数据做决策”。如果你已经在做图形学、游戏客户端或实时渲染相关工作,这篇文章可以当作一份性能优化起步清单来用。
1. 核心能力速览
从方法论层面看,游戏性能优化其实是“定位瓶颈 -> 建立对比基线 -> 修改一项参数 -> 验证影响”的循环。下面把这套方法涉及到的核心能力点列出来,方便你判断它适不适合你当前阶段:
| 能力项 | 说明 |
|---|---|
| 优化对象 | CPU 耗时、GPU 耗时、Draw Call、内存占用、显存占用、发热功耗 |
| 核心思路 | 稳定复现问题、量化性能数据、单变量修改、对比验证效果 |
| 关键工具 | Profiler、Frame Debugger、RenderDoc、Nsight、PIX、GPU-Z、自定义性能日志 |
| 典型案例 | 3 万棵树的场景渲染优化,覆盖大世界地形、密集建筑、植被放置等场景 |
| 适用引擎 | Unity、Unreal、自研引擎,只要具备 Profiler 或性能分析接口即可 |
| 数据量化 | 帧时间、Draw Call、SetPass Call、三角面数、Overdraw、内存占用 |
| 批量验证 | 自动化场景回放、固定相机路径、Python 脚本批量采集与对比 |
| 适合读者 | 游戏客户端开发、图形学入门、TA、想建立优化工作流的工程师 |
这套方法不是“一键优化”。它不会给你一个万能的参数,也不会在五分钟内把 3 万棵树变成 30 帧稳定 60 帧。它的价值在于:当项目卡顿时,你能在半小时内判断瓶颈在 CPU 还是 GPU,在渲染管线的哪个阶段,影响最大的一项设置是什么。
2. 适用场景与使用边界
渲染 3 万棵树的场景,本质上是“海量物体实时渲染”的缩影。大型开放世界的地形、密集建筑群、大量植被、大量粒子效果,甚至 UI 上几百个控件的叠加,都会遇到相似的问题。这套方法在以下场景中最有用:
- 场景加载或大世界移动时帧率明显波动。
- 帧率在某个视角下骤降,换个方向就恢复。
- CPU 占用很高但 GPU 占用很低,或者反过来。
- 想评估 LOD、遮挡剔除、阴影质量、贴图压缩对性能的实际影响。
- 需要向团队提交一份可复现、可对比的性能报告。
使用边界同样要清醒。性能优化是增量改进,不是架构重构。如果你的项目渲染管线的根基有问题,比如每帧都同步读取 GPU 数据,那靠调 LOD 距离和合批策略是救不回来的。另外,优化一定会牺牲一部分画质表现,需要提前明确可接受的最低画质标准。不要为了帧率把阴影全关了、把贴图全部降成 256,最后画面回到十年前,哪怕帧率再高也没有实际意义。
还有一个边界常被忽略:优化前必须确认资产使用合规。如果场景中的树木模型、贴图、地形工具来自第三方素材商店,修改 LOD 配置和贴图压缩格式通常没问题,但如果要裁剪纹理内容、替换作者署名、或以优化后的效果进行商用宣传,需要确认原始素材的授权范围。这不算技术问题,但却是工程师在发布和交付时容易踩的合规坑。
3. 环境准备与前置条件
要想复现“渲染 3 万棵树”这个场景,并把性能数据记录下来,你需要准备四类环境。
3.1 引擎或渲染项目
优先选择你日常工作的项目。这里以 Unity 和 Unreal 为例,因为它们的 Profiler 和调试工具比较完整,但方法本身不挑引擎。
在 Unity 中,至少需要开启以下工具:
- Profiler 窗口(Window -> Analysis -> Profiler),用于观察 CPU/GPU 耗时。
- Frame Debugger(Window -> Analysis -> Frame Debugger),用于逐 Draw Call 查看渲染状态。
- Stats 面板(Game 视图右上角 Stats),用于快速看 Batches、SetPass Calls、Triangles。
Unreal 中对应的是:
- Session Frontend / Unreal Insights,用于捕获帧时间线和性能数据。
- RenderDoc 插件或 Unreal 内置的 Render Debugger,逐 Draw Call 检查状态。
- stat unit、stat gpu、stat sceneRendering 等控制台命令,快速查看耗时分布。
自研引擎则更直接一点:在渲染线程和游戏逻辑线程中打点,输出每帧的 CPU 时间、渲染指令数量和 GPU 时间即可。关键是数据要能落盘,不能只看控制台实时输出。
3.2 硬件监控工具
Profiler 负责告诉你每一帧在哪里耗时,硬件监控工具负责告诉你 GPU 的真实状态。
推荐准备:
- GPU-Z:查看 GPU 占用率、显存占用、温度、功耗。
- 系统任务管理器或资源监视器:查看整体 CPU 占用和内存占用。
- 如果使用 N 卡,可以开启 NVIDIA FrameView 或 PIX。如果使用 A 卡,可以使用 AMD RGP 或 OCAT。
这些东西不是必须全部装上,但至少要有一种能实时看到 GPU 占用率的工具,因为很多帧率卡顿都来自显存爆掉和 GPU 过热降频。特别是长时间跑场景时,功耗墙和温度墙会直接影响帧率稳定性,这通常被忽略。
3.3 固定测试场景
这是整套方法论里最核心的部分。正式优化之前,先搭一个“可复现的测试场景”。所谓可复现,就是每次测试都满足以下三个条件:
- 相机路径固定,比如沿一条固定轨道运行 20 秒。
- 场景内容固定,比如 3 万棵树的位置、数量、模型、材质全部一致。
- 时间固定,比如帧率记录从第 1 秒到第 20 秒。
为什么强调这一点?因为性能优化最怕“无法复现”。如果每次跑场景相机角度都不一样,帧率波动到底是优化带来的,还是镜头转动带来的,根本无法判断。固定测试场景相当于做实验时的控制变量。在 Unity 中可以用 Timeline 或 Cinemachine 固定相机路径,在 Unreal 中可以用 Sequencer 录制一条巡游路径。
3.4 数据分析环境
性能数据最终是一堆数字,直接用眼睛看 Excel 表格很容易漏掉变化趋势。建议准备 Python 环境,用 pandas 和 matplotlib 做可视化分析。这一步不是必须的,但如果要做“优化前 vs 优化后”的对比,图表会比数字直观得多,也方便贴进性能报告里。后面我会给出一个可以直接跑的 Python 脚本模板。
4. 第一次跑场景:先测数据,再谈优化
在动任何参数之前,先完整跑一遍场景,采集第一组基线数据。这一步的核心目标,是回答三个问题:
- 帧率到底是多少?在哪个时间段掉得最厉害?
- 每一帧的主要耗时在 CPU 还是 GPU?
- Draw Call、SetPass Call、三角形数量、显存占用大概是什么量级?
4.1 记录帧时间线
不要只记录平均帧率。平均帧率会骗人,比如 20 秒跑下来平均 30 FPS,但实际表现是前半段 60 FPS、后半段 10 FPS,反应在体感上就是“走走停停”。
正确做法是把每一帧的 CPU 耗时、GPU 耗时、帧时间分别记录下来。Unity 中可以用 Profiler 的“Frame Time”和“GPU Time”,Unreal 中用 stat unit 里面 Frame、Game、Draw、GPU 这几项。
4.2 简单做一个日志导出
在 Unity 中,你可以借助 Profiler 的自定义采样接口,把关键数据写进日志。比如这样:
using UnityEngine; using UnityEngine.Profiling; public class PerformanceSampler : MonoBehaviour { private float frameTime; private float cpuTime; private float gpuTime; private int drawCalls; private int triangles; void Update() { frameTime = Time.unscaledDeltaTime * 1000f; cpuTime = Profiler.GetTotalAllocatedMemoryLong() / (1024f * 1024f); // 这部分数据在编辑器中有效,打包后需要通过 Profiler 连接获取 drawCalls = UnityStats.batches; triangles = UnityStats.triangles; // 输出到控制台,正式环境可以写成文件 Debug.Log($"{Time.frameCount},{frameTime:F2},{cpuTime:F2},{drawCalls},{triangles}"); } }如果是在正式发布包中收集数据,更稳妥的方式是把数据写入文件,而不是依赖 Debug.Log。下面是一个最小可用的文件写入版本:
using System.IO; using System.Text; using UnityEngine; public class PerfDataWriter : MonoBehaviour { private StringBuilder sb = new StringBuilder(); private float timer; private string filePath; void Start() { filePath = Path.Combine(Application.persistentDataPath, "perf_data.csv"); sb.AppendLine("frame,frame_time_ms,cpu_ms,draw_call"); } void Update() { timer += Time.unscaledDeltaTime; if (timer >= 20f) // 采集 20 秒 { File.WriteAllText(filePath, sb.ToString()); Debug.Log("save to " + filePath); enabled = false; return; } float frameMs = Time.unscaledDeltaTime * 1000f; sb.AppendLine($"{Time.frameCount},{frameMs:F2},{Time.deltaTime * 1000f:F2},{UnityStats.batches}"); } }Unreal 则更简单,直接用 Unreal Insights 捕获一帧的完整时间线,或者用 stat unit 输出到日志,再配合定时截图就能定位问题帧。
4.3 初步判断瓶颈方向
采集完数据之后,先用这张表做粗判断:
| 表现 | 瓶颈方向 | 优先检查项 |
|---|---|---|
| CPU 帧时间高,GPU 帧时间低 | CPU bound | 游戏逻辑、物理、动画、AI、渲染线程提交 |
| GPU 帧时间高,CPU 帧时间低 | GPU bound | 填充率、Overdraw、阴影、后处理、纹理带宽 |
| CPU 和 GPU 都高 | 并行瓶颈 | 垂直同步、帧率上限、驱动层同步等待 |
| 显存占用接近上限 | 显存瓶颈 | 纹理格式、Mipmap、网格顶点数、RT 数量 |
这一步不要求精确定位到某个函数,只要知道方向就行。后面所有的优化动作都要围绕这个方向展开。换句话说,如果第一步判断是 CPU bound,那你花几个小时去调阴影距离、压缩贴图,收益会很有限。
5. 从 3 万棵树逐步拆解渲染瓶颈
这里进入重头戏。把 3 万棵树放进场景后,帧率如果不理想,通常不是某一个单独的原因,而是好几个因素叠加。下面按对渲染性能影响从大到小的顺序,一套一套检查。
5.1 Draw Call 爆炸
先做一道算术题。假设一棵树由树干、树枝、树叶三部分组成,每部分一个 Mesh,每个 Mesh 需要 1 个 Draw Call。那么 3 万棵树在最粗暴的渲染方式下,每帧至少需要 9 万次 Draw Call。哪怕树的模型本身只有几百个三角形,这个数量也足以让 CPU 的渲染线程崩溃。
经验上,移动端单帧 Draw Call 控制在 100 到 300 之间比较稳妥,PC 端根据 CPU 性能不同,普遍能承受 1000 到 3000。而 9 万这个数字,无论放在哪个平台都是灾难级别的。
首先要确认场景里实际的 Draw Call 数量,而不是估算。Unity 中可以在 Stats 面板直接看 Batches,Unreal 中可以用 stat SceneRendering 查看 DrawCall 相关统计。如果数字已经上万,那核心优化动作就是减少批次数。
一个立竿见影的方案是 GPU Instancing。把大量相同的 Mesh 一次性提交给 GPU,只传一份 Mesh 数据和一份材质数据,然后通过 Instance ID 区分每个物体的位置、旋转、缩放、颜色。3 万棵树如果全部使用同一棵树的 Mesh,并且允许 Instancing,理论上可以压缩到几个 Draw Call,因为引擎会按材质和 Mesh 自动分组。
Unity 中开启 GPU Instancing 的步骤:
- 选中树的材质球,在 Inspector 中勾选 Enable GPU Instancing。
- 使用支持 Instancing 的标准着色器或 URP 的 Lit Shader。
- 批量生成树时使用同一棵树的 Mesh 和同一个材质实例。
// 用 Graphics.DrawMeshInstanced 批量渲染树的示例 public class TreeInstancer : MonoBehaviour { public Mesh treeMesh; public Material treeMaterial; public int count = 30000; private List<Matrix4x4> matrices = new List<Matrix4x4>(); private MaterialPropertyBlock propertyBlock; void Start() { Vector3 pos = Vector3.zero; for (int i = 0; i < count; i++) { pos.Set(Random.Range(-200f, 200f), 0f, Random.Range(-200f, 200f)); matrices.Add(Matrix4x4.TRS(pos, Quaternion.identity, Vector3.one * Random.Range(0.8f, 1.5f))); } propertyBlock = new MaterialPropertyBlock(); } void Update() { Graphics.DrawMeshInstanced(treeMesh, 0, treeMaterial, matrices, propertyBlock); } }这段代码的意思是每帧提交 3 万个实例,但批次数大幅下降,因为 GPU 一次可以处理大量实例。当然,600 米乘 600 米范围内的随机位置分布,还需要配合相机裁剪,而不是真的把 3 万个实例全部显示在屏幕上。
Unreal 中对应的是 Instanced Static Mesh(ISM)和 Hierarchical Instanced Static Mesh(HISM),后者是 HISM 的升级版,专门用于植被和大量重复物体,自带 LOD 管理和遮挡剔除数据。在编辑器中选中树木资产,使用“Merge Actors”或直接创建 ISM 组件并批量添加实例即可。
但要注意:Instancing 只对“相同 Mesh + 相同材质”的物体有效。如果你的 3 万棵树里有 30 种不同的树模型,那至少要有 30 个批次;如果每种树又使用了 10 种不同颜色的材质变体,批次数会重新膨胀。所以在项目最初整理资产时,尽量限制树种数量和材质变体数量,这是性价比最高的策略。
5.2 合批失败与动态批处理
如果不走 GPU Instancing,引擎的自动合批也能减少一批 Draw Call,但限制比想象中严格。Unity 的动态批处理只适用于小网格物体,并且顶点数、缩放、材质等都有约束,一旦超限就自动放弃合批。静态批处理则要求在运行时不能移动物体,而且会复制一份合并后的网格到内存,3 万棵树如果做静态合批,内存开销可能直接爆炸。
判断合批是否成功,最直接的方式是打开 Frame Debugger 查看每一帧的 Draw Call 列表。如果在“Batching”状态里看到大量的“Dynamic batching failed”或“Static batching failed”,就说明合批条件不满足。
合批失败的最常见原因:
- 材质实例不同。哪怕是同一个材质,只要通过 MaterialPropertyBlock 或材质实例上设置了不同的颜色,就可能拆批。
- 顶点属性过多。网格包含多个 UV 通道、顶点色、法线切线时,动态批处理非常容易失败。
- 使用了不支持的着色器特性,比如阴影、雾效果在不同 Pass 中的设置不一致。
所以,与其依赖引擎自动优化,不如主动使用 GPU Instancing 或 HISM 来管理大量重复物体。这也印证了一个原则:大规模重复场景中,“主动减批”比“依赖自动合批”可靠得多。
5.3 LOD 距离设置
即使 Draw Call 问题解决了,3 万棵树全用最精细的模型也会让 GPU 的顶点处理和像素填充不堪重负。这时候要用 LOD(Level of Detail)策略。
LOD 的核心思想:离相机远的物体,用低精度模型渲染;离相机近的物体,用高精度模型渲染。3 万棵树如果全部显示最高模,哪怕三角形数量再小,也是几十万甚至上百万三角形的量级,对 GPU 的压力很大;但如果你把远距离的树切换成 1/10 面片的低模,三角形总量会骤降一个数量级。
在 Unity 中,给一棵树配置 LOD 的常规做法:
- 在树模型的根节点添加 LOD Group 组件。
- 提供 3 到 4 个 LOD 级别:LOD0 为高模,LOD1 为中模(面片数约为高模的 50%),LOD2 为低模(约为高模的 10%),LOD3 可以是不透明面片或广告板(Billboard)。
- 设置 LOD 切换百分比。比如 LOD0 在屏幕占比大于 60% 时使用,LOD1 在大于 30% 时使用,LOD2 在大于 10% 时使用,低于 10% 全部使用 Billboard。
对大量植被来说,远端使用 Billboard 或跨面片是一个非常有效的优化手段。因为树的纹理本来就是大片的绿色和棕色,远处的细节几乎不可见,用面片替代三维模型完全不影响观感。这里要注意 BillBoard 的朝向,禁用雾和阴影,同时限制采样数量,否则会变成新的性能开销。
5.4 遮挡剔除与视锥剔除
3 万棵树分布在 600 米范围,相机一次只能看到其中一部分。如果场景里有山体、建筑或其他大树挡住了后面的树,那后面的树其实完全没必要渲染。但默认情况下,引擎的视锥剔除只能剔除“不在相机视野内”的物体,对于“在视野内但被挡住”的物体无能为力。
所以要启用遮挡剔除(Occlusion Culling)。Unity 中需要先选中场景中所有静态物体,在 Inspector 的 Static 下拉框中勾选 Occluder Static 和 Occludee Static,然后打开 Window -> Rendering -> Occlusion Culling 窗口,设置一个合理视野范围和裁剪距离,点击 Bake。Unreal 中只需要把静态物体设为 Static,并在 World Settings 中启用相关选项,或使用 HISM 自带的数据生成机制。
遮挡剔除的效果非常直接。相机在树林中穿行时,视线被近处的树挡住,远处的树会被剔除,Draw Call 进一步下降。但要注意:如果树的 LOD 模型在远处切换成广告板,广告板作为“遮挡物”的精度很低,可能无法正确遮挡后面的物体,这时需要手动调整遮挡数据的精度设置,或者让广告板不参与遮挡剔除的生成。
5.5 材质复杂度与 Pass 数量
一个物体渲染一次,可能需要执行多个 Pass。比如一个标准材质要渲染一遍主颜色,再渲染一遍阴影 Pass,受多个灯光影响时还要渲染额外的 Pass。2 万次的 “SetPass Calls” 通常比 “Draw Calls” 更能说明渲染管线的压力,因为 SetPass Calls 代表的是“切换一次材质渲染状态”,状态切换是非常昂贵的操作。
在 3 万棵树的场景里,最容易犯的错误是让树的材质参与阴影 Pass,但还不降精度。每棵树都用一个高精度 Mesh 去生成 Shadow Map,GPU 的负担瞬间被放大。正确做法:
- 树的主 Mesh 和 Shadow Pass 分别使用 LOD。
- 远距离树的阴影可以关闭,或改用统一的低精度假阴影。
- 避免一棵树上叠加太复杂的材质,比如同时包含镜面反射、视差、法线、自发光、动态 GI 多层叠加,每叠加一层都是一份 GPU 开销。
可以在 Frame Debugger 中查看单个 Draw Call 使用了几个 Pass。如果发现一棵正常的树要渲染 4 到 5 遍,那这就是最重要的优化对象。简化材质和减少 Pass 之后的收益,几乎是立竿见影的。
5.6 实时阴影优化
实时阴影是整个渲染管线中最容易被忽视的性能杀手。这里的核心开销不在“绘制物体”,而在“生成 Shadow Map”。根据阴影距离、阴影分辨率、级联层数、阴影贴图数量,一棵树的阴影开销可能比渲染树本身还要高。
针对 3 万棵树的场景,建议从这几个方向入手:
- 缩短阴影距离。只让近距离物体投射实时阴影,远距离树直接用烘焙光照或假阴影代替。
- 降低阴影贴图分辨率。从 2048 降到 1024,画面观感可能几乎不变,但 GPU 压力会小很多。
- 使用更少的级联层数。PC 上常用 4 级联,移动端常用 2 级联。
- 关掉小物体的阴影。在树 LOD1 和 LOD2 上取消 Cast Shadows,只让 LOD0 的近树产生阴影。
- 阴影 culling:树或地形已经遮挡住的部分,不要让它再次参与 Shadow Map 生成。
实践中,一般建议先把所有树的实时阴影全部关掉,跑一次基准数据;再逐步打开不同距离和不同品质的阴影,观察帧率变化。这样你能很清晰地看到“阴影”这一项到底占了多少性能预算。
5.7 纹理与采样开销
3 万棵树的面片数量降低之后,GPU 的压力会从几何处理转移到像素处理。每一棵树至少使用一张漫反射贴图,某些 PBR 材质还会使用法线贴图、粗糙度贴图、环境光遮蔽贴图,一套 4K 贴图在纹理缓存里占用的带宽相当可观。
注意几个细节:
- 纹理尺寸不要超过实际需要。如果一棵树在屏幕上最多占据 200 像素,给它一张 2048 纹理就是一种浪费。可以生成 Mipmap 后,观察 Mipmap 在远处切换时占用了多少内存。
- 纹理压缩格式要选对。PC 上使用 BC7,移动端使用 ASTC 或 ETC2。把未压缩的 RGBA8 纹理直接丢进移动包,显存占用会直接起飞。
- 关掉不必要的采样器。比如树的材质里有一个不需要的高度图通道,能省则省。
从下面的测试流程可以看到,纹理优化的收益是“润物细无声”的,它不会让帧率从 20 变成 60,但会显著降低显存占用和发热量,尤其在低端移动设备上,往往决定了能不能稳定运行。
6. 性能数据采集与批量对比:把优化过程做成闭环
到这里,你已经掌握了不少优化手段。但实际操作中,你不会一次性做完所有优化,而是每次只改一个参数,然后看它对性能的影响。这就需要一套自动化的对比流程。
6.1 固定相机路径
在 Unity 中,用 Timeline 加 Cinemachine 相机;在 Unreal 中,用 Sequencer。无论使用哪种,核心都是让相机沿固定路径巡游固定的 20 秒或 30 秒。这段路径要覆盖近处、中景、远处和有遮挡的情况,这样一次采集就能看到不同状态下的帧率变化。
6.2 批量运行和采集
在固定相机路径的前提下,用不同配置多次运行场景。例如:
- 配置 A:当前项目默认配置。
- 配置 B:开启 GPU Instancing、关闭远距离阴影。
- 配置 C:B 的基础上增加 LOD 切换、遮挡剔除。
- 配置 D:C 的基础上压缩纹理、降阴影分辨率。
每一轮运行结束后,把性能数据导出成 CSV 文件,文件名带上配置编号。
6.3 Python 脚本分析对比
下面是一个可以直接运行的 Python 脚本示例,用于读入不同配置的 CSV 文件,计算平均帧时间、P95 和绘制对比图:
import pandas as pd import matplotlib.pyplot as plt # 假设 CSV 包含列: frame, frame_time_ms, cpu_ms, draw_call files = { "A_default": "perf_A.csv", "B_instancing": "perf_B.csv", "C_lod_occ": "perf_C.csv", "D_texture_shadow": "perf_D.csv", } results = {} for label, path in files.items(): df = pd.read_csv(path) p95 = df["frame_time_ms"].quantile(0.95) avg = df["frame_time_ms"].mean() results[label] = {"avg_ms": avg, "p95_ms": p95} print(f"{label}: avg {avg:.2f} ms, P95 {p95:.2f} ms") summary = pd.DataFrame(results).T summary.plot(kind="bar", figsize=(10, 5)) plt.title("Performance Comparison") plt.ylabel("Frame time (ms)") plt.tight_layout() plt.savefig("perf_comparison.png")这段脚本会输出平均帧时间和 P95 帧时间两个指标。为什么重点看 P95?因为平均帧时间会被大多数流畅帧掩盖,P95 代表的是“95% 帧的耗时水平”,更能体现卡顿的严重程度。一个好的优化,会让平均帧时间下降,同时让 P95 和平均值的差距缩小,也就是“帧率更稳”。
6.4 对比时的注意事项
- 每次只改一个变量。如果同时改了 LOD 和阴影,你无法判断帧率提升到底是哪一个带来的。
- 测试期间关闭后台程序。Steam、浏览器、录屏软件都可能干扰帧率,尤其影响 GPU 占用数据。
- 保存场景的快照或版本号。用 Git 或 SVN 保存每次改动前后的配置,方便回退。
- 用热键或命令行切换配置。避免每次都要重新打开编辑器、重新导入资源。
7. 资源占用与性能观察
在完成每一轮优化之后,除了帧时间,还要观察硬件层面的资源占用。只有当帧时间下降、资源占用也更合理时,优化才是真正有效果的。
7.1 显存和内存
3 万棵树的场景,大量纹理、网格、LOD 数据都会占用内存。通过 Profile 工具可以查看 Asset 在内存中的总量。纹理是最大的内存占用来源,其次是网格数据。当显存逼近上限时,GPU 会开始使用共享内存,导致帧率大幅度波动。
观察方式:在 Unity 编辑器中打开 Profiler 的 Memory 面板;在 Unreal 中使用 stat memory 或 Unreal Insights 的 Memory 跟踪;发布到真机上可以用 Android Studio Profiler 或 Xcode Instruments 查看。
降低内存占用的通用手段:
- 设置正确的 Mipmap 流送(Texture Streaming),只在需要时加载特定 Mip 层。
- 使用异步加载和资源卸载,避免所有树模型一股脑全部进入内存。
- 网格压缩开启,把不必要的通道剔除。
- 对超远距离的树直接使用合并网格或广告板,不加载完整 LOD0 数据。
7.2 CPU 和 GPU 占用率
优化不是只看帧率,还要看硬件占用率。如果优化后帧率达到 60,但 GPU 占用率还是 99%,说明场景仍然接近 GPU 的满载极限,后续加入其他玩法或敌人时,帧率可能瞬间崩塌。理想状态是给 GPU 留出 20% 到 30% 的余量。
CPU 占用也要分开看:游戏线程、渲染线程、Worker 线程各自的时间占比。Unity 的 Profiler 会自动把这些线程分开,Unreal 的 stat unit 里也有对应显示。如果渲染线程耗时很高,说明 Draw Call 提交或状态切换仍然是瓶颈;如果游戏线程耗时很高,说明逻辑、物理或动画占用了过多时间片,这类问题跟渲染无关,需要单独排查。
7.3 发热和功耗
移动端性能优化的最终归宿,不是帧率,而是功耗和温度。高帧率会带来高功耗,高功耗会带来高温,高温会导致降频,降频又会让帧率崩掉。所以在移动端跑 3 万棵树这类场景时,观察电池温度、SoC 温度和功耗曲线是很重要的一步。
常用的工具是 PerfDog、Snapdragon Profiler 或厂商自带的功耗分析工具。如果在固定帧率下发热仍然严重,说明 GPU 的峰值负载太高,需要继续降低填充率或阴影分辨率。
8. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 帧率低但 GPU 占用不高 | CPU bound,渲染提交耗时过长 | Profiler 查看渲染线程耗时 | 减少 Draw Call、使用 GPU Instancing、合并网格 |
| GPU 占用率 99%,帧率还是低 | GPU 达到性能上限 | 查看 Fill Rate、Overdraw、阴影 | 降低分辨率、减少后处理、关高分辨率阴影 |
| Draw Call 数量很大但合批失败 | 材质实例过多或不满足合批条件 | Frame Debugger 查看失败原因 | 统一材质、减少材质变体、使用相同 Tiling |
| 远处树消失或闪烁 | LOD 切换阈值设置不合理 | 查看 LOD 切换百分比 | 调整 LOD 切换距离,开启 CrossFade 或 Dither 过渡 |
| 物体被遮挡但仍被渲染 | 未启用遮挡剔除或数据错误 | 检查 Occlusion Culling 数据和静态标记 | 正确标记 Occluder/Occludee,重新 Bake |
| 移动端发热严重 | GPU 峰值负载高 | 观察功耗曲线 | 降阴影分辨率、限制帧率、降低纹理带宽 |
| 优化后画面质量明显下降 | 参数改得过多 | 对比截图和视频 | 恢复部分画质选项,找到平衡点 |
| 显存占用快速增长 | 大量未压缩纹理或 Mipmap 加载 | 查看内存分析 | 使用压缩格式、开启纹理流送、清理无用资源 |
上面这个表只是高频问题的起点。实际项目里千奇百怪的坑还有很多,比如相机裁剪距离和阴影级联配合不当导致远景树在半透明状态闪烁、GPU Instancing 与逐物体颜色无法共存要改用 MaterialPropertyBlock、遮挡剔除生成时静态物体包含树叶导致数据异常庞大等等。遇到这类问题,最好的方式就是回到“稳定复现、量化数据、单变量验证”这套方法里,一步一步拆。
9. 最佳实践与使用建议
这里把整套方法论中的实践经验整理成一个可落地的清单,适合在项目启动初期就建立规范。
9.1 先定目标再优化
优化前先明确目标帧率和目标设备。比如“中端 Android 机型稳定 30 FPS,高端 PC 稳定 60 FPS”。有了目标,所有优化动作就有了判断标准。否则优化永远没有尽头,今天降了阴影,明天又想降贴图,画质一路下滑。
9.2 建立最小可运行配置
把优化后的关键配置固化成一个“最小可运行配置”,比如:
- LOD 距离统一使用某个默认值。
- 阴影距离和分辨率使用保守值。
- 纹理压缩格式统一使用发行版本。
- 树的数量、批次、Draw Call 上限记录在文档中。
后续每次进入新场景,都先以这个配置为基准运行,再根据效果逐步放宽。
9.3 一次只改一个变量
字面意思。改 LOD 只改 LOD,改阴影只改阴影。对比时如果同时改了“开启遮挡剔除”和“降低贴图大小”,出了性能提升也无法判断哪个生效。强烈建议用 Git 分支保存每一版优化配置,这样每轮测试前后可以快速回退和对比。
9.4 批量任务必须加日志和失败重试
如果要做大规模场景,批量生成 3 万棵树、批量配置 LOD、批量压缩纹理,脚本化任务一定要加日志和失败重试。比如用编辑器脚本批量替换材质时,中途如果某个资源报错,不能中断整体流程,而应把失败资源记录到日志文件,结束后统一排查。下面是一段 Unity 编辑器批处理脚本的最小示例:
using System.IO; using UnityEngine; using UnityEditor; public class BatchTreeProcessor : EditorWindow { private string logPath = "Assets/tree_process_log.txt"; private int processedCount; [MenuItem("Tools/Batch Process Trees")] public static void Open() { GetWindow<BatchTreeProcessor>(); } private void OnGUI() { if (GUILayout.Button("批量配置树 LOD")) { processedCount = 0; string[] guids = AssetDatabase.FindAssets("t:Prefab", new[] { "Assets/Trees" }); using (StreamWriter writer = new StreamWriter(logPath, false)) { foreach (string guid in guids) { string path = AssetDatabase.GUIDToAssetPath(guid); GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path); if (prefab == null) { writer.WriteLine($"[Error] Failed to load: {path}"); continue; } Debug.Log($"[Tree] Processed: {path}"); processedCount++; } } AssetDatabase.SaveAssets(); Debug.Log($"处理完成,共 {processedCount} 个资源,日志: {logPath}"); } } }把这个脚本编译进编辑器后,通过菜单点击即可批量处理树资源,并通过日志追踪失败项。类似思路也可以用于批量压缩纹理、批量设置 LOD 百分比。
9.5 接口服务要限制访问范围
如果你把优化后的场景做成了远程调试接口或性能数据上报服务,比如在构建包中开启 HTTP 接口供测试机回传数据,这个接口只允许内网访问,并且要加鉴权。否则任何人都能往你的后台发送任意帧数据和文件路径,轻则造成日志污染,重则通过路径遍历读取服务器文件。不要给调试接口开公网访问,这是最低成本的安全要求。
9.6 合规与发布前检查
- 涉及人脸、声音、品牌资产、受版权保护的模型和贴图时,必须在优化、压缩、二次修改前确认授权范围。
- 发布或商用前,对优化后的画质和性能做一次整体复核,避免某个设置导致画面质量不可接受。
- 优化不是无限制压缩,画质和性能之间要有底线。
10. 总结与下一步
这次我们用“渲染 3 万棵树”作为测试场景,把游戏优化从“凭感觉调参”变成了一套可执行的方法论。核心就这三句话:先稳定复现,再量化数据,最后单变量验证。具体到这次实战,最先要做的事情是打开 Profiler 记录第一组基线数据,判断瓶颈方向在 CPU 还是 GPU,然后再去尝试 GPU Instancing、LOD、遮挡剔除、阴影优化和纹理压缩。最容易踩的坑则是“一次改太多参数”和“只看了平均帧率没看 P95”,这两个问题会让你的优化完全无法归因。
接下来你可以按照这个方法去建立自己的场景基准:先准备一个固定巡游路径,然后跑一次默认配置,导出 CSV 数据,再用 Python 脚本画出对比图。完成这一轮之后,这套流程就可以复制到开放世界地形的城市街区、密集 NPC、大型粒子系统、UI 界面等各种性能敏感场景中。优化的核心不是某一个技巧,而是形成一套稳定可复用的诊断闭环。建议把这篇文章里的清单收藏备用,下次项目卡顿的时候直接按流程走一遍,你会比大多数人更快找到瓶颈。