ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

3万棵树的渲染性能优化:Draw Call与帧时间

3万棵树的渲染性能优化:Draw Call与帧时间 图形学里的性能诊断与优化和普通后端的性能优化有本质区别。渲染一帧CPU 要提交渲染命令GPU 要执行光栅化中间隔着命令缓冲、顶点处理、像素着色、深度测试等一串环节。只要其中一个环节成为瓶颈整帧帧率就会掉下去。网上常看到用批处理关后台服务、调电源计划的“游戏优化方案”这些解决的是电脑外围环境真正吃掉渲染帧时间的往往还是绘制链路本身。这次用一个非常典型又非常极端的场景来演示在场景里渲染 3 万棵树。树的网格相同材质相同位置随机分布在半径 500 米的区域内。这个场景跑起来之后Draw Call 数量直接上万帧率掉到 20 上下。接下来要做的不是立刻减模型面数而是先建立测量链路定位瓶颈在 CPU 还是 GPU再按几何、可见性、状态切换、Shader 四个层逐级优化最后用帧时间分位数据验证收益。这套顺序可以直接搬到真实项目里换成 1 万个小兵、100 座建筑、10 万株草叶方法论不变。1. 先建立优化坐标系渲染性能问题为什么不能靠猜1.1 一帧画面的成本构成渲染一帧画面可以从三个角色去理解成本CPU 应用线程更新游戏逻辑、计算 Transform、执行裁剪然后把绘制指令写进图形 API 的命令缓冲。CPU 渲染线程驱动层把命令翻译成 GPU 能执行的控制流提交到 GPU。GPU执行顶点着色、光栅化、像素着色、深度测试、混合最后把结果写回帧缓冲。一帧的总耗时并不是三段简单相加。帧率由最慢的环节决定这个环节就是瓶颈。如果 CPU 编码命令的速度跟不上 GPU 的执行就是 CPU Bound此时 GPU 常常在空转等待如果 GPU 执行过于繁重就是 GPU BoundCPU 提交得再快也没有用。区分这两者是所有渲染优化的起点。方向做反了后面所有调整都会事倍功半。1.2 新手最容易踩的三个误区第一帧率低就立刻减面数。如果瓶颈在 CPU 的 Draw Call 提交减面只降低了 GPU 的顶点负担CPU 照样被命令提交卡住帧率几乎不动。第二GPU 占用高就一刀切降低画质。GPU 占用高还要继续细分是顶点数太多是像素填充率不够还是显存带宽被纹理采样耗尽。不同子瓶颈的解法完全不同。第三用编辑器里的 Profiler 数据当作生产数据。编辑器自身每秒会消耗不少 CPU 和 GPU同一个场景在编辑器里是 20 FPS打包出来可能已经到 60 FPS。方向判断可以用编辑器数据最终验证必须在真机 Release 包上做。1.3 一套可以复用的渲染优化顺序推荐按下面的顺序推进每一步都只改一个变量采集基线帧率、Draw Call、SetPass Call、三角形数量、Overdraw、帧时间 P95。确认瓶颈是 CPU 还是 GPU。CPU Bound 时细分是脚本逻辑、物理、动画还是渲染提交。GPU Bound 时细分是顶点、光栅化、像素还是带宽。针对最具体的那一层做一轮优化重新跑同一条相机路径。对比基线和优化后的 P50、P95 帧时间。重复直到命中性能预算表。这套顺序不依赖具体引擎。Unity、Unreal、自研引擎都能用区别只是工具名称和统计字段叫法不同。2. 搭建可复现的压测工程3 万棵树从脚本生成2.1 环境与前置条件演示环境用 Unity 2022.3 LTS 加上 URP 渲染管线。选择这个组合的原因是它默认具备 SRP Batcher、GPU Instancing 支持和内置 Profiler做渲染优化演示最省事。换成自研引擎也可以只要把统计项改成本引擎的调试输出。环境要求如下表项目建议值说明引擎Unity 2022.3 LTS 或更高低版本 URP 接口有差异渲染管线Universal Render Pipeline便于观察 Batch 效果测试平台独立显卡 PC1080p 分辨率保证有足够性能余量可对比测试树模型一棵约 3000 三角形、带单张树冠贴图的树避免美术资源成为变量统计工具Unity Profiler、Frame Debugger、Stats 面板分别负责帧耗时、提交事件、汇总数值模型选择有讲究。树的三角形数量不要太多保持在几千的级别否则压测场景会演变成顶点瓶颈看不清楚 Draw Call 层面的问题。树冠贴图使用带透明通道的 PNG这样场景里天然存在 overdraw方便观察像素阶段开销。2.2 用脚本生成 3 万棵树的森林不要手动摆放 3 万棵树。写一个生成脚本用固定种子做随机分布保证每次生成的场景一致方便对比优化前后数据using UnityEngine; public class ForestGenerator : MonoBehaviour { public GameObject treePrefab; public int count 30000; public float radius 500f; public int seed 20240601; void Start() { Random.InitState(seed); for (int i 0; i count; i) { Vector3 pos RandomPointInCircle(radius); pos.y 0f; Quaternion rot Quaternion.Euler(0f, Random.Range(0f, 360f), 0f); float scale Random.Range(0.8f, 1.4f); GameObject tree Instantiate(treePrefab, pos, rot, transform); tree.transform.localScale Vector3.one * scale; } } Vector3 RandomPointInCircle(float r) { float angle Random.Range(0f, Mathf.PI * 2f); float dist Mathf.Sqrt(Random.Range(0f, 1f)) * r; return new Vector3(Mathf.Cos(angle) * dist, 0f, Mathf.Sin(angle) * dist); } }这段代码有三个关键点用固定种子初始化随机数保证每次跑场景树的分布一致。树干放在 y0 平面相机绕场景中心转一圈时远近遮挡关系和真实森林更像。每个 GameObject 是独立个体这是刻意复现最差情况。真实项目很少这样放树但压测就是要从最差情况起步后面才能看见优化的绝对收益。2.3 记录基线帧时间与 Draw Call场景跑起来后先记录基线。基线阶段不要急着调任何参数只采集数据。帧时间用简单的计时脚本输出using UnityEngine; public class FrameStats : MonoBehaviour { float timer 0f; void Update() { timer Time.deltaTime; if (timer 0.5f) { Debug.Log($FrameTime: {Time.unscaledDeltaTime * 1000f:F2} ms); timer 0f; } } }Draw Call 数量不要靠估算用 ProfilerRecorder 直接采样using UnityEngine; using UnityEngine.Profiling; public class DrawCallStats : MonoBehaviour { ProfilerRecorder drawCallRecorder; void OnEnable() { drawCallRecorder ProfilerRecorder.StartNew(ProfilerCategory.Render, Draw Calls Count); } void OnDisable() { drawCallRecorder.Dispose(); } void Update() { if (drawCallRecorder.Valid) { Debug.Log($DrawCalls: {drawCallRecorder.LastValue}); } } }运行后会在 Console 里看到类似下面的数据FrameTime: 48.13 ms | DrawCalls: 31042 FrameTime: 47.02 ms | DrawCalls: 30897 FrameTime: 49.66 ms | DrawCalls: 31120这组数字就是基线。3 万棵树对应 3 万左右 Draw Call帧时间接近 50ms换算过来只有 20 帧左右。后面每一步优化都要回到这组数字做对比。注意基线数据必须在同一台机器、同一分辨率、同一条相机路径下采集。换任何一项后续对比都会失真。3. 用 Profiler 定位瓶颈CPU 还是 GPU再细到哪个阶段3.1 关键指标怎么读Unity Profiler 的 Raw Hierarchy 里有三组数据决定方向指标含义指向PlayerLoop脚本 Update、物理、动画总耗时CPU 应用线程Rendering渲染提交与状态绑定时耗CPU 渲染线程GPU设备端 GPU 实际执行耗时GPU 执行阶段WaitForTargetFPS帧尾等待垂直同步的时间帧率限制或垂直同步打开 Profiler 采集一帧按耗时排序。如果 Rendering 占比最高说明 CPU 渲染线程在拼命提交如果 PlayerLoop 很高则要先处理脚本逻辑如果 CPU 数据都正常但总体帧时间仍然很高需要看 GPU 耗时。这里有个容易被忽略的点WaitForTargetFPS 高不代表性能好它只说明 GPU 提前完成了CPU 在等垂直同步如果它一直很高但帧率没有达到目标就要检查帧率设置和屏幕刷新率。3.2 Frame Debugger 能看到什么Frame Debugger 按事件顺序列出这一帧提交的所有内容。打开方式在 Window - Analysis - Frame Debugger录制后逐条翻看。对 3 万棵树场景重点看三点同一个树 Mesh 是否被拆成了几万个独立 Draw Call。一个模型在 Shadow Map 通道、Depth 通道、主颜色通道各提交了几次。材质是否完全一致是否具备合批条件。翻到阴影通道时经常发现一棵树占 3 到 4 个 Draw Call主颜色一次、阴影一次、URP 的深度预通道可能还有一次。3 万棵树乘上去开销立刻翻倍。这也是 3 万棵树最终可能产生 9 万到 12 万次提交的原因。3.3 没有 Profiler 时的四步二分法如果用的是自研引擎或者 Profiler 数据不可用可以用四个实验做二分定位相机对准空旷天空。帧率恢复说明瓶颈在场景内容仍然低说明全局管线或后处理问题。关闭阴影。帧率明显提升说明 Shadow 通道消耗大。把分辨率降到很低。帧率提升说明像素填充率或带宽吃紧。逐步减少树的个数比如先减到 5000。帧率提升说明顶点或 Draw Call 是主因。四步做完至少能确定瓶颈在哪一层再回到对应优化手段。4. 第一层优化把 3 万次 Draw Call 压到 30 次4.1 Draw Call 为什么贵Draw Call 不是“叫 GPU 画一下”这么简单。每次调用图形 API 都要验证管线状态、绑定顶点缓冲、检查纹理与帧缓冲状态这些工作主要在 CPU 侧完成。状态切换越多CPU 越忙。场景里有 3 万个相同网格、相同材质的树却提交了 3 万次本质是信息没有合并。如果能把 1000 棵树的变换矩阵塞进一次调用CPU 开销就能下降一个数量级。这就是 GPU Instancing 的价值。4.2 GPU Instancing 最小落地在 URP 中先在材质面板勾选 Enable GPU Instancing。然后不再实例化 GameObject而是提前生成所有树的矩阵列表using System.Collections.Generic; using UnityEngine; public class TreeMatrixBuilder : MonoBehaviour { public int count 30000; public float radius 500f; public int seed 20240601; public ListMatrix4x4 BuildMatrices() { Random.InitState(seed); var list new ListMatrix4x4(count); for (int i 0; i count; i) { float angle Random.Range(0f, Mathf.PI * 2f); float dist Mathf.Sqrt(Random.Range(0f, 1f)) * radius; Vector3 pos new Vector3(Mathf.Cos(angle) * dist, 0f, Mathf.Sin(angle) * dist); Quaternion rot Quaternion.Euler(0f, Random.Range(0f, 360f), 0f); float scale Random.Range(0.8f, 1.4f); list.Add(Matrix4x4.TRS(pos, rot, Vector3.one * scale)); } return list; } }提交时按 1023 个实例一组拆分这是多数图形 API 对单次 Instanced 调用的稳定上限using System.Collections.Generic; using UnityEngine; public class InstanceRenderer : MonoBehaviour { public Mesh treeMesh; public Material treeMaterial; public int count 30000; public float radius 500f; ListMatrix4x4 matrices; void Start() { matrices GetComponentTreeMatrixBuilder().BuildMatrices(); } void Update() { int batchSize 1023; int offset 0; while (offset matrices.Count) { int rest Mathf.Min(batchSize, matrices.Count - offset); var slice matrices.GetRange(offset, rest).ToArray(); Graphics.DrawMeshInstanced(treeMesh, 0, treeMaterial, slice, rest); offset rest; } } }注意不要在循环里拿单实例数组反复调用 DrawMeshInstanced那等于造了 3 万个 Instanced 调用反而更慢。如果 Shader 是自己写的需要加上实例化数据块UNITY_INSTANCING_BUFFER_START(PerTree) UNITY_DEFINE_INSTANCED_PROP(float, _ScaleFactor) UNITY_INSTANCING_BUFFER_END(PerTree)顶点函数里通过UNITY_ACCESS_INSTANCED_PROP(PerTree, _ScaleFactor)取每棵树自己的数据。注意DrawMeshInstanced 单次调用的实例数量有平台相关上限常见做法是按 1023 一组拆分避免在部分平台上超出限制。4.3 静态合批与 SRP Batcher 的选型Instancing 不是唯一合并思路。静态合批会把静止的网格合并成一个大 Mesh适合地面、建筑、栅栏这类完全不动的物体但树木若有风摆动画或者需要实时位置变化就不适合。SRP Batcher 走另一条路减少材质状态绑定。只要 Shader 兼容它能把同类物体的材质属性绑定开销合并让 CPU 在状态准备上大幅提速前提是材质种类稳定、Shader 遵守 SRP Batcher 兼容规则。三者的选择方案适合对象不适合对象主要代价GPU Instancing大量相同网格材质差异大的多个物体需要 Shader 支持Static Batching完全静止的物体会移动、会变形的物体内存上升发布包变大SRP Batcher材质种类稳定的场景不兼容的 Shader需要调整 Shader 结构真实项目里三个手段通常组合使用而不是只选一个。4.4 常见坑Instancing 明明开了却不生效现象勾选 Enable GPU Instancing 后Draw Call 数量没有下降。排查顺序材质是否真的使用同一个 Material 实例而不是复制了 3 万份材质。Shader 是否在数据块里声明了实例化属性。Multi-pass Shader 会导致每个 Pass 独立提交。是否用了 MaterialPropertyBlock 修改了部分实例属性这会破坏合批条件。打开 Frame Debugger看 Draw Call 名称是否带 Instanced 标记Stats 面板中 Saved by batching 是否出现明显增长。多数情况出在材质实例不共享或 Shader 不兼容上。5. 第二层优化剔除看不见的树减少 GPU 无效负载5.1 视锥剔除解决不了森林问题Draw Call 数量下降后GPU 顶点负担可能仍高3 万棵树几乎都在视锥内视锥剔除只能裁掉相机背后的树。森林里大量树彼此遮挡真正能被看到的可能只有几千棵。把看不见的树挡在光栅化之前就是遮挡剔除的目标。Unity 默认开启视锥剔除但遮挡剔除需要额外配置和烘焙。5.2 遮挡剔除与遮挡数据烘焙URP 项目的操作路径是打开 Window - Rendering - Occlusion Culling 面板配置烘焙参数。把会挡住视线的大型物体标记为 Occluder Static把树这类被遮挡对象标记为 Occludee Static。设置 Smallest Occluder 与 Smallest Hole 参数后点击 Bake。运行时通过 Camera.useOcclusionCulling 控制是否启用再用可视化视图确认剔除结果。参数理解如下参数含义调大调小Smallest Occluder能产生遮挡的最小物体尺寸遮挡物少剔除弱遮挡物多烘焙更准Smallest Hole能穿过光线的空洞尺寸更容易遮挡更不容易遮挡烘焙时如果 Cell 过大剔除粒度粗远处的遮挡关系不准确Cell 过小内存占用上升。需要按场景尺度试验。5.3 深度预通道和 Early-Z 的判断光栅化阶段GPU 会先做深度测试。如果能提前知道当前片元的深度比深度缓冲里的值更远就可以直接丢弃不再执行像素着色这叫 Early-Z。为了保证 Early-Z 收益部分管线会单独做一次深度预通道。Depth Prepass 的代价是几何要提交两遍。判断是否留下它的方法打开管线的帧耗时统计对比 Depth 和 Opaque 两个 Pass 的耗时。如果 Opaque 的像素着色时间在启用 Prepass 后明显下降说明 overdraw 严重预通道值得保留。如果只是顶点时间直接翻倍说明场景遮挡不明显关掉预通道更划算。树的树冠贴图带透明通道时如果使用 Alpha Clip 而非透明混合深度写入更可控Early-Z 对主体树干也能生效。5.4 常见坑阴影通道把收益偷偷吃回去森林场景最容易被忽略的是实时阴影。每棵树在 Shadow Map 通道里要重新提交一次几何阴影贴图分辨率越高GPU 负担越大。验证方法在相同的相机路径下开关阴影观察帧时间变化。如果关闭阴影后 P95 帧时间下降超过 20%就应该限制阴影距离或者为树的阴影使用独立低模代理而不是沿用主网格。6. 第三层优化LOD、纹理与 Shader压低单位三角形成本6.1 LOD 粒度和预算怎么定距离越远的树人眼能分辨的细节越有限。LOD 的思路是准备多套网格按距离切换让 GPU 永远只处理当前距离下合适的精度。以一个 3000 三角形的树为例可以设计 4 级LOD 级别适用距离三角形预算说明LOD 00 到 30 米3000玩家身边最近的一批树LOD 130 到 80 米1200中景保留主干和轮廓LOD 280 到 200 米400远景只保留大致形状Billboard200 米以上2 到 4十字交叉面片或 Impostor在 Unity 里用 LOD Group 组件把每级对应 MeshRenderer 挂进去并开启 CrossFade避免距离切换时出现明显的模型跳变。6.2 Billboard 和 Impostor 的选择Billboard 是两张交叉的三角形面片贴一张树的截图。它几乎不消耗顶点但相机旋转时画面会轻微变形。Impostor 更好一些把树从多个方向预渲染成多张贴图运行时按相机角度选图。视觉效果好但贴图占用的显存更大。选择建议低端移动端的远景树优先用 Billboard省显存省带宽。PC 端追求画质、有显存余量时用 8 方向 Impostor。如果树木还有风力摆动需求Impostor 和 Billboard 都很难表现需要保留少量顶点做顶点动画。6.3 纹理、Mipmap 和 Alpha 通道顶点量降下来后像素阶段会成为新的瓶颈。森林场景里最典型的隐患是树冠贴图没有生成 Mipmap远处仍从最高分辨率纹理采样显存带宽被浪费。使用半透明混合树冠边缘和重叠部分会产生大量 overdraw。纹理长宽比不规整采样效率低于规范的 2 次幂尺寸。在贴图 Import Settings 里确认 Generate Mip Maps 勾选。树冠这类有透明通道的贴图优先使用 Alpha Clip 替代半透明混合。把 Scene 视图切到 Overdraw 模式白色区域密集的地方就是像素着色重复最多的区域也是优先优化的位置。6.4 常见坑阴影还在用高模树已经切到了 LOD 2但帧率还是没降。打开 Frame Debugger 看阴影通道发现阴影 Mesh 仍是 LOD 0 的高模。原因是阴影使用的 Cast Shadows 模式绑定在 MeshRenderer 上不会自动跟随 LOD 切换。处理办法给阴影额外准备一个几百三角形的 Shadow Proxy 模型。通过自定义 ShadowCaster Pass在阴影通道里输出简化几何。限制阴影距离让远处树不参与实时阴影。7. 验证优化结果帧时间分位、真机 Release 和性能预算7.1 为什么平均帧率会骗人平均帧率 60不等于没有卡顿。一次 300ms 的大卡顿会拉低平均帧率但它只出现一次日常小卡顿造成的帧时间毛刺反而更影响手感。更好的观察对象是帧时间分布统计每一帧的毫秒数看 P95、P99。P99 高说明存在较长卡顿P95 高说明整体压力偏大。7.2 采集帧时间分位的方法固定一条相机路径绕森林旋转 60 秒记录每一帧的毫秒数。路径里要包含近距离观察树冠、中距离越过大片树、远距离看整片森林三种视野覆盖不同遮挡与 LOD 组合。把记录导出成 CSV用数据分析工具计算分位。相比只输出平均帧率的工具这组数据能更真实反映游戏手感。注意验证优化收益时以 Release Build 在真机上的 P95 帧时间为准。编辑器和 Development Build 只用于定位问题不做最终判定。7.3 学习环境、Development Build 与 Release 的差异环境数据可信度适用阶段编辑器低有 Editor 额外开销功能确认、粗糙方向判断Development Build 连 Profiler中接近真机仍有调试开销定位问题、采集调用链Release Build高接近用户侧最终帧时间和内存验证生产环境还要额外核对后处理是否在低配机上被自动降级。阴影距离、反射探针数量、实时灯数是否超出预算。内存峰值是否触发 GC 或系统回收。是否预留了渲染配置回滚开关比如动态分辨率、质量等级。7.4 维护一张性能预算表每个项目都建议在开发早期建立性能预算并随版本更新持续维护消耗项60 FPS 预算实际值是否达标帧时间16.6 ms待测Draw Call≤ 500待测SetPass Call≤ 150待测三角形总数≤ 500 万待测Overdraw 平均≤ 2待测显存占用平台阈值内待测内存峰值平台阈值内待测预算表的价值不是定一个绝对标准而是让每次优化都有对比基准。没有基准的优化讨论不出结果。8. 渲染性能问题排查清单与方法论沉淀8.1 一条从现象到根因的排查路径把整篇文章的排查顺序压缩成一条可以贴在显示器边的清单固定相机路径采集 Baseline帧时间 P50/P95、Draw Call、SetPass、三角形、Overdraw。确认 CPU Bound 还是 GPU Bound。CPU Bound看 PlayerLoop 与 Rendering 两段脚本逻辑与渲染提交分开量。GPU Bound拆成顶点、光栅化、像素、带宽四类用降分辨率、开关阴影、改 LOD 来隔离。定位后只改一个变量重新跑同一路径。对比基线P95 提升才算有效优化。没有提升就回滚换下一个假设而不是多个变量同时调。8.2 森林渲染常见问题速查表现象可能原因检查方式处理建议帧率低但 Draw Call 不高顶点多或像素填充高查看三角形数降低分辨率对比LOD、减面、降低 overdraw帧率低且 Draw Call 极高单体提交过多Frame Debugger 数事件GPU Instancing、静态合批阴影一开就卡阴影分辨率过高、阴影用高模开关阴影对比帧时间限制阴影距离、Shadow Proxy远处树闪烁LOD 切换无过渡、Mipmap 缺失拉远视角反复观察开启 CrossFade、生成 MipmapProfiler 里 CPU 不高但帧率低GPU 瓶颈或垂直同步限制看 GPU 计数器和 WaitForTargetFPS继续拆 GPU 子阶段编辑器优化明显真机没有编辑器与真机管线差异打 Release 包再测以真机 Release 结果为准Instancing 开了没效果材质不共享、Shader 不兼容检查 Frame Debugger 与材质引用简化材质使用实例化数据块8.3 方法论测量链路永远比直觉可靠回到标题里的问题游戏优化到底怎么开始答案不是从“减少模型面数”开始也不是从“调画质”开始而是从建立一条可以重复的测量链路开始。先把性能预算表填上把相机路径固定好把每次改动记下来然后按 CPU/GPU 边界逐层拆解。3 万棵树只是演示用的极端样本。真实项目里遇到瓶颈时只要测量链路还在就能一步步定位到命令提交、顶点处理、像素填充或者带宽中的某一个具体环节。图形学性能优化的核心竞争力从来不是记住某几个按钮而是知道瓶颈为什么在那里以及改哪一层最合适。
返回列表