这是随手记得,很杂,在做的过程中一直改,还未整理,仅供参考
总述
帧预算,什么时候优化,使用工具知道是什么引发的,然后找到原因,最后修复





Insights通常用于CPU、文件、内存等等,GPU方面的内容我们需要捕获对应的数据使用ProfileGPU更好

那么“最低的果子”是CPU还是GPU最简单的方法是使用stat unit
stat unit
- Frame: 即一帧所耗费的总时间,这个值越大,fps 就越小,二者相乘恒等于 1
- Game: 处理游戏逻辑所耗费的时间 这一步完全不考虑渲染问题,表现的是整个游戏世界在一帧之内,只在逻辑层面处理所有的变化需要花多长时间**——Compute Game Context
- Draw: 准备好所有必要的渲染所需的信息,并把它从 CPU 发送给 GPU 所耗费的时间 承接上一步,在游戏世界在逻辑层完成所有的计算和模拟后,收集渲染所需的信息,并剔除非必要信息,通知 GPU 进行画面渲染**—— What to Render
- GPU: 接收到渲染所需信息之后,将像素最终的表现画在屏幕上的耗时
-
RHIT: Render hardware interface time,硬件接口渲染时间。简单来说RHI类定义了一些硬件平台的公有变量和接口,专门用来让UE4和D3D,OpenGL这些实现通信。至于为什么要独立这么一个线程,则是为了分离场景遍历,可见性这类任务和RHI命令提交任务。相关参考https://news.nweon.com/59536
- prims:Represents 3D data as “prims”: static meshes, skeletal meshes, HISM, Materials, Lights, Cameras, Variants, Animation, and Blend shapes.

- Game Thread 首先会对整个游戏世界进行逻辑层面的计算与模拟(e.g.Spawn 多少个新的 actor、每个 actor 在这一帧位于何处、角色移动、动画状态等等),所有这些信息会被输送到 Draw Thread
- Draw Thread(也叫 Rendering Thread) 会根据这些信息,剔除(Culling)掉不需要显示的部分(e.g. 处于屏幕外的物体),接着创建一个列表,其中包含了渲染每个物体必备的关键信息(e.g. 如何被着色、映射哪些纹理等等),再将这个列表输送给 GPU Thread
- GPU Thread 在获取了这个列表之后,会计算出每个像素最终需要如何被渲染在屏幕上,形成这一帧的画面
- 综上,对于每一帧来说,这三者的执行顺序依次为:Game Thread → Draw Thread → GPU Thread
Game Thread
Game Thread 造成的开销,基本可以归因于 C++ 和蓝图的逻辑处理,瓶颈常见于Tick 和代价昂贵的逻辑实现(Expensive Functionality)
Tick
大量物体同时 Tick 会严重影响 Game Thread 的耗时
stat game:显示 Tick 的耗时情况
dumpticks:可将所有正在 tick 的 actor 打印到 log 中

- 复杂逻辑
需要借助 Unreal Frontend Profiler / Unreal Insights 等工具对游戏逻辑中开销较大的代码进行定位
Draw Thread (Rendering Thread)
Draw Thread 的主要开销来源于 Visibility Culling 和 Draw Call
Visibility Culling
Visibility Culling 会基于深度缓存(Depth Buffer) 信息,剔除位于相机的视锥体(Frustum)之外的物体和被遮挡住(Occluded)的物体,当游戏世界中可见的物体过多,剔除所需的计算量也将变大,导致耗时过长
Stat InitViews:显示 Visibility Culling 的耗时情况,同时还能显示当前场景中可见的 Static Mesh 的数量(Visible Static Mesh Elements)

Draw Call
一般理解:CPU 准备好一系列渲染所需的信息,通知 GPU 进行一次渲染的过程
想象 CPU 指挥 GPU 拿起一支笔刷,蘸好颜料,给某个(或者某一些)多边形(polygon)涂上颜色,来自 CPU 的这条指令就是一次 Draw Call
很多情况下,不同的多边形(可能属于不同的 mesh)需要的是同一种颜色(材质),那么在给笔刷蘸好颜色之后,可以一次性给这些多边形上色,而不需要做无谓的重复操作,这个过程就叫做 合批*(batching)*
UE 官方解释:*a group of polygons sharing the same material (一组使用相同材质的多边形)*. 首先举例来理解:场景中有 100 个多边形(polygon),其中 10 个共同使用材质 A,10个共同使用材质 B,剩余 80 个共同使用材质 C,100 个多边形被分成了 3 组,于是 Draw Call 就等于 3
结合之前的一般理解,也可以理解为:CPU 命令 GPU 将笔刷蘸上某一材质对应的颜料,然后一次性给若干个 polygon 上色,这条 CPU 下达的指令就是一次 Draw Call,而这些 polygon 就是 *one group of polygons sharing the same material*,有多少组这样的 polygon,就等于发生了多少次 Draw Call
NVIDIA 在GDC上曾提出 25k batch/sec的渲染量会使1GHz的CPU达到100%的使用率,因此使用公式 25K∗n(GHZ)∗Percentage/Framerate=Batch/Frame 可以推算出某些CPU可以抗多少Batch。 例如红米手机CPU为1.5GHz,假设分出20%资源供渲染,希望游戏跑到30帧。 那么能抗多少Draw Call? 25k * 1.5 * 0.2 / 30 = 250。 因此从这方面也能看出,如果CPU不能分出更多的资源供渲染计算,能抗的Draw Call就会变少。
Stat SceneRendering 可查看 Mesh Draw Call 的数量
即便场景中模型面数多,只要合批机制完善,Draw Call 的数量也可以非常少
相比于面数,Draw Call 对性能开销的影响要大得多

GPU Thread
顶点处理(Vertex-bound) 导致的瓶颈
Dynamic Shadow
- 目前动态阴影(Dynamic Shadow)的生成主要依赖 Shadow Mapping,一种在光栅化阶段计算阴影的技术,Shadow Mapping 每生成一次阴影需要进行两次光栅化,因此当顶点数过多(可能源于多边形数量巨大,也可能源于不适当的曲面细分) 时,Dynamic Shadow 将成为 GPU 在光栅化阶段的一大性能瓶颈
- ShowFlag.DynamicShadows 0: 使用该指令可关闭场景内的动态阴影(0表示关闭,1表示开启),可在开启和关闭两种状态间反复切换,查看卡顿情况是否发生明显变化,以此判断 Dynamic Shadow 是否确实造成了巨大开销
着色(Pixel-bound) 导致的瓶颈
-
运行指令 r.ScreenPercentage 50,表示将渲染的像素数量减半(也可替换成其他 0-100 之间的数),观察卡顿现象是否明显减缓,以此判断瓶颈是否 Pixel-bound
-
-
Find objects that take a lot of time to do vertex calculations using Graphics Debugger
-
- To Eliminate pixel shader costs,
- - set r.screenpacentage to a very low value.
- Capture and check cost of each draw call with GD
- - (Set r.RHISetGPUGaptureOptions to 1, and then “ProfileGPU”)
-
-
Shader Complexity
-
- 显示对每一个像素所执行的着色指令数量,数量越多,消耗越大
- 场景中存在过多的半透明物体(Translucent Object),会显著增加 Pixel Shader 的计算压力,使用 stat SceneRendering 可查看 Translucency 的消耗情况;使用 ShowFlag.Translucency 0 来关闭(0表示关闭,1表示开启)所有半透明效果
- 当着色器(材质连线)的实现逻辑过于复杂或低效时,也会导致较高的 Shader Complexity
- 在 Viewport 中选择 Optimization Viewmodes → Shader Complexity,可视化 Shader 造成的开销


Quad Overdraw
- 着色期间 GPU 的大部分操作不是基于单个像素,而是一块一块地绘制,这个块就叫 Quad,是由 4 个像素 (2 × 2) 组成的像素块
- 当模型存在较多狭长、细小的三角形时,有效面积较小,但可能占用了很多 Quad,Quad 被多次重复绘制,会导致大量像素参与到无意义的计算中,引起不必要的性能开销

- 进入 Optimization Viewmodes → Quad Overdraw,显示 GPU 对每个 Quad 的绘制次数

Light Complexity
- 场景内的动态光源(Dynamic Lights) 数量过多时,会产生大量动态阴影(Dynamic Shadow),如上述所说,容易引起较大开销
- 动态光源的半径过大,导致多个光源的范围出现大量交叠,也可能导致严重的 Overdraw 问题
- 进入 Optimization Viewmodes → Light Complexity,查看灯光引起的性能开销

Unreal Insight
https://docs.unrealengine.com/5.0/zh-CN/unreal-insights-in-unreal-engine/
trace.send localhost cpu,gpu,…启用Unreal Insights追踪


参数-statnamedevents,显示各个 trace event 的名字和开销情况;其中 event 的名字,就是代码中 TRACE_CPUPROFILER_EVENT_SCOPE(xxx) 括号内的部分,而开销情况所涵盖的范围,就是这个宏的作用域
在 UE 源码中全局搜索关键字 UE_TRACE_CHANNEL_DEFINE,可查看所有支持开启的 trace channel


默认开启的 channel 可以在 Engine/Config/BaseEngine.ini 中进行配置
[Trace.ChannelPresets] Default=cpu,gpu,frame,log,bookmark
trace.stop 停止跟踪

在Timing Insights中单击某帧会高亮显示此时再次点按F键会放大下面的高亮区域

单击选中某个 trace event 后按回车(或者按住 Ctrl + 双击该条目),该事件所对应的时长区间将被自动框定
选中某个 trace event 后按 F,可快速将视角聚焦于此条目


按住shift加鼠标滚轮可以上下缩放MainGraph

Callers & Callees
显示当前所选 event 的调用关系
Callers 显示的是历经了哪些函数调用,才到达了当前所选的 event
Callees 显示的是进入所选 event 后,又继续向下调用了哪些函数

使用启动命令行添加参数,Memory Insights, Asset Loading Insights 才能被打开


想要查看内存泄漏,在有memory通道的游戏中打开然后使用gc.CollectGarbageEveryFrame 1

不关心Low Level Memory(LLM)清除即可,如果想加回来点加号即可

现在可以看到堆栈中每一次内存分配,然后使用下方,在内存泄漏ABC中查询,选择时间跨度A、B,然后拖动C到某个位置,意思是从A到B发生的内存分配然后在C后释放




ProfileGPU
捕获当前一帧的数据,如果在打包版本记录,那么log文件保存在游戏的Saved文件夹中,文件中的LogRHI数据就是了,但不是按照耗时排序的

按Duration时间降序先“摘最低的果子”

找到是什么后,去场景中寻找对应的东西然后看为什么
Unreal Frontend – Profiler(会话前端)
https://zhuanlan.zhihu.com/p/441501920
怎么做
LOD

可以在LOD Settings中改变LOD数量应用更改后引擎会自动进行LOD,如果我们希望自定义引擎在多远的距离上显示指定的LOD只需要勾选LOD Picker中的Custom然后取消选中Auto Compute LOD Distances就可以对每个LOD进行设置

如果使用自己的模型并添加自己的LOD就可以在LOD Settings中的LOD Import导入即可

在关卡中选择Mesh LOD Coloration进行网格LOD着色使我们知道我们的网格当前是什么LOD


骨架网格物体
-
r.skeletalmeshlodbias:添加/减去 LOD 级别
-
r.skeletalmeshlodscale:缩放 LOD 距离
静态网格体
-
r.StaticMeshLODDistanceScale:缩放 LOD 距离
-
r.forceLOD:强制LOD级别
灯光

调整绘制距离和衰减距离有助于优化
可扩展性
https://docs.unrealengine.com/5.2/zh-CN/scalability-and-the-developer-for-unreal-engine/
有时候使用GPU Profiler 时会看到Unbatched Lights,那么在允许的情况下可以考虑静态光或者关闭Cast ShadowHow to optimize unbatched lights ?
剔除
遮挡剔除
有时候遮挡剔除不生效,我遇到过这样的情况:在安卓预览下使用FreezeRendering命令可以看到视椎剔除有用,但遮挡剔除下大部分没有剔除掉,需要进行预计算可视性等操作,并且需要先构建灯光然后构建预计算可视性
杂项
内存(Memory-bound)引起的瓶颈
- 有时性能瓶颈还在于过高的内存占用,其中最常见的是大量的纹理(Texture)加载和采样
- 使用 *stat streaming overview*,查看当前纹理对内存的占用情况

UI纹理不会被流式传输,不会有mipmap,他们加载最完美的图案,一直存在于内存中只要有引用,即使看不到也会被加载出来,因为有引用他的其他东西被加载,比如:主UI引用了血条UI,血条UI引用了一个用不到的UI,但是忘记删除这个变量了,而且这个UI要显示一个非常大的UI类型纹理,那么他会在整个游戏流程中一直存在于内存中,即使没人用他没人看得到他。Fix:按需加载,让引用不是一个类引用,而是软引用即可
Stats性能埋点
按值传递 与 按引用传递+内部拷贝
按值传递(pass-by-value)
int32 DeleteObjects(TArray<UObject*> InObjectsToDelete, ...);
发生了什么?
- 调用时会 立即拷贝整个数组的内容 到函数栈上。
- 这个拷贝是 无条件的,无论函数是否真的需要修改副本。
缺点:
- 如果外部传进来的数组很大,但函数实际并没有使用它,或者只是读取一部分数据,那么这次拷贝就是 不必要的开销。
- 在某些场景下,
TArray的拷贝还可能涉及内存分配(heap alloc),影响性能。
按引用传递 + 内部拷贝
int32 DeleteObjects(const TArray<UObject*>& InObjectsToDelete, ...)
{
TArray<UObject*> ObjectsToDelete = InObjectsToDelete;
发生了什么?
- 外部传进来的是一个引用,没有发生拷贝。
- 只有当函数逻辑确实需要一份可修改的副本时,才会执行一次拷贝。
优点:
- 延迟拷贝(lazy copy):只有在必要时才拷贝。
- 更灵活控制拷贝时机:例如你可以根据条件决定是否拷贝、拷贝多少内容。
“传引用 + 内部拷贝”不是多此一举,而是一种性能优化手段,特别是在处理大型数据结构(如 UE 中的 TArray)时。 所以:两者都发生了拷贝。但它们发生的时机和必要性不同,因此性能表现也不同。如果你是在编写性能关键或引擎级别的代码(比如 Unreal Editor 工具链),这种写法是非常推荐的。
| 方式 | 是否立即拷贝 | 是否可控 | 性能影响 | 适用场景 |
|---|---|---|---|---|
| 按值传递 | 是(调用时) | 否 | 可能高(尤其大数组) | 数据小、不关心性能 |
| 按引用传递 + 内部拷贝 | 否(调用时不拷贝) | 是 | 更优 | 大对象、性能敏感代码 |
参考
UE4 性能 - (一)瓶颈定位https://zhuanlan.zhihu.com/p/438543980
[UF2022]虚幻引擎游戏性能优化秘笈(官方字幕)
https://www.bilibili.com/video/BV1He4y1s729/?spm_id_from=333.337.search-card.all.click&vd_source=7e66f11d9fdbda24189a1ee2ad49f9ab
Unreal Insights https://docs.unrealengine.com/5.0/zh-CN/unreal-insights-in-unreal-engine/
[UF2022]蓝图优化最佳实践(官方字幕)
https://www.bilibili.com/video/BV1ZP4y1y7Pn/?spm_id_from=333.337.search-card.all.click&vd_source=7e66f11d9fdbda24189a1ee2ad49f9ab
Unreal TickFunc调度https://zhuanlan.zhihu.com/p/467438700