浅谈Unity的渲染优化(1): 性能分析和瓶颈判断(上篇)
前言
本系列文章聚焦于Unity的渲染优化。在国内,大部分3D手游开发以Unity3D为主。然而,由于Unity不开源,多数用户难以在架构和API层面进行改造与优化。因此,本文不会涉及过多底层优化方法,而是以易懂的方式阐述如何在设计和使用中规避问题、利用优势。“渲染”这一主题将文章范围限定在图像表现相关内容。当前互联网上许多Unity图像优化的分享,主要围绕Unity的使用注意事项和建议。但随着硬件性能提升、用户需求增长以及竞品游戏的挑战,一些通用优化方案可能变得无效甚至产生误导。美术创意和特效也可能因优化考虑不足而被放弃。各芯片厂商的分析工具和优化指南与Unity的实现及具体制作缺乏直接关联。本文旨在为解决这些问题提供更具建设性的意见和方法,增强优化意识,提升图形表现和游戏性能,改善用户体验,如降低耗电和散热等。
由于时间有限,且为论点提供更多数据和测试支持,本文将拆分为多个章节发布。除本章节的性能分析和瓶颈判断外,CPU、GPU、内存使用各部分的优化将根据内容量占1 - 3节,Unity自身问题的对应和解决占1 - 2节,架构和API相关内容将根据具体实现效果,决定放在最后还是单独开启一个优化系列。计划每周更新一节。此外,在一些可深入了解的地方会提供扩展阅读链接,这些内容在后续分享架构和API优化时会详细涉及,建议有兴趣但未深入接触过的读者阅读。
性能分析和瓶颈判断
本节概述
本章节虽围绕分析展开,但关键在于,无论使用何种引擎,其底层均通过API与终端硬件交互。为充分发挥硬件性能,优化应针对底层API和硬件进行分析。以这种意识分析和解决性能瓶颈,甚至有意识地制作场景和特效,将对游戏性能和效果提升大有裨益。其中,问题分析大多由具备程序开发背景的技术人员负责,难度不大,许多项目已引入该流程。然而,在保证制作的同时兼顾优化,由程序推动美术工作,对程序人员和美术人员的执行力都是挑战。且在制作后期发现瓶颈,返工成本较高。从技术美术角度看,应在制作中期对可能引发瓶颈的效果进行评估和优化。本文将尽量简化技术术语,以便有一定游戏开发基础的读者理解。
本节内容包括:
- 了解必要的基础理论。
- 将效果和表现的制作消耗转化为与API和硬件相关的指标。
- 掌握瓶颈分析与判断的方法。
基础理论
图形管线简介
对于“图形管线”的定义,本文沿用【A trip through the Graphics Pipeline 2011】(中译名:图形管线之旅)的介绍。这里的图形管线不仅涵盖D3D/OpenGL图形管线,还涉及这些3D API在操作系统和GPU上的运作原理,即了解API“为什么这样做”和“如何去做”(这些对于OS/GPU方面仅是上层描述)。
为避免开头过于枯燥,本文以3D API D3D在Windows上运行于DX11等级硬件的环境为例进行介绍(主要是缺乏移动方面合适资料,且大家对PC环境更熟悉)。
引用资料和图片来源:
- A trip through the Graphics Pipeline 2011(图形管线之旅,cnblog上可找到中译版)
- AMD Comments on GPU Stuttering, Offers Driver Roadmap & Perspective onBenchmarking : The Start: The Rendering Pipeline In Detail
- [Windows驱动程序入门(了解用户模式(User - Mode)和内核模式(Kernel - Mode)即可)](https://msdn.microsoft.com/zh - cn/library/windows/hardware/ff554836(v = vs.85).aspx)
若已充分了解相关内容,可跳过以下关键点描述。
首先,引用下图对Windows的图形渲染管线(Graphic Rendering Pipeline)进行高层次预览,【图形管线之旅】中称其为“Software Stack”:
结合【图形管线之旅】第一节的描述(链接),该流程的核心思想为:我们调用API(Draw Call)并非直接操作GPU,而是需经过操作系统和驱动处理,最终通过总线传输至GPU。具体每个绘制阶段将在后续优化实例中提及。
- Application:可以是简单的3D应用、3D游戏或游戏引擎。在上图中,App可简略分为模拟器(Simulator)和渲染器(Renderer)。模拟器负责更新游戏世界,如每帧的动画、物体对象位置更新等,模拟结果由渲染器创建Draw Calls,通过DirectX或OpenGL的API生成一帧。
- API(Direct3D) Runtime:通过API创建资源(Resource)、状态(State)和绘制(DrawCall),负责跟踪状态、分析验证参数、处理错误、检测一致性、验证Shader、Link Shader(OpenGL在驱动层),然后传递给用户驱动(User - Mode Driver)处理。
- (Direct3D) User Mode Driver (or UMD):即玩家熟悉的“图形驱动”。例如,新PC游戏卡顿或出现bug,更新显卡驱动后问题解决。在PC上,它是由GPU开发商提供的DLL,如“Nvd3dum.dll”(NVidia)或“atiumd*.dll”(AMD),与APP运行在同一Context和地址空间。UMD负责Shader的高层和底层优化,如循环优化、分支预测、寄存器分配等,还能针对特定游戏和硬件进行优化。Shader的创建/编译在第一次Draw Call运行并使用时执行,因此使用新Shader物体首次显示时可能出现卡顿。此外,UMD还负责内存管理,通过KMD分配内存,为Texture分配空间,以及将API Runtime的输出转化为GPU可处理的Command Buffer(或DMA Buffer),再传回API Runtime。
- Context Queue:UMD完成工作后,将Command Buffer传递到Context Queue(上下文队列),也称为[AMD] Flip Queue / [Nvidia] Pre - rendered Frames Queue。其目的是排列Command Buffer,确保CPU和GPU同步,避免STALL。CPU提交绘制命令后,GPU渲染需时间,将当前帧绘制放入队列可让CPU继续下一帧模拟和渲染提交,提高效率,但会增加延迟。DX12硬件规格之前的PC,默认CPU比GPU快三帧,即实际看到的画面是CPU三帧前模拟和提交渲染的。移动终端的GLES也有类似延迟处理方式,通用的Tiled - Base的GPU架构中,CPU处理第N - 2帧,N - 1帧进行GPU顶点处理,N帧进行GPU片元处理,也存在延迟问题。
- DXG Kernel Scheduler:由于UMD是应用进程中的DLL,多个应用进程调用GPU时,需调度器决定访问顺序,确保同一时间只有一个应用进程可提交command到GPU。
- Kernel Mode Driver (or KMD):实际处理硬件,只有一个实例。负责管理Command Buffer,初始化重置GPU,向Main Command Buffer写入系统和初始化指令以及真正的3D指令,供GPU使用。
- System Bus:PC平台上,CPU通过PCI Express总线访问GPU,移动终端芯片大多为SoC,无bus。
- Command Processor:GPU前端,负责读取KMD写入的Command buffer,后续由GPU进行工作。
最后,将一帧渲染到GPU的back - buffer后,调用Direct3D Present() 或OGLeglSwapBuffers(),标志一帧绘制结束,将back - buffer内容显示到前端。GPU方面的详细介绍将结合后续具体优化方案进行。
CPU优化基础
与渲染管线类似,CPU优化也涉及一些硬件相关的低级概念。由于Unity源码限制,无法直接在架构设计或API调用上进行优化,因此从最近几款iOS移动设备芯片的比较入手,了解设计优先的游戏引擎的潜力。
| Soc | 对应设备 |
|---|---|
| A8X | iPad Air 2 |
| A8 | iPhone 6 |
| A7 | iPad Air / iPhone 5S / iPad Mini2 |
| A6X | iPhone5 / iPad4 |
相关CPU性能参数如下:
- CPU:如3x "Enhanced Cyclone" 表示3核。
- CPU Clockspeed:每个CPU核心的时钟频率。
- RAM:内存,虽对游戏效率无直接影响,但内存不足会导致游戏运行困难或崩溃。
- L1 ~ L3 Cache:CPU高速缓存,如A8X的L1 Cache为每个核64 KB指令 + 64 KB数据。
在配置电脑或购买手机时,CPU核心数量和时钟频率是厂商宣传重点,通常认为核越多、主频越高,设备性能越好。目前,除最新iOS设备iPhone6S内存为2GB外,大部分为1GB;Android设备中,小米note增强版达到4GB,多数旗舰机为3GB。
由于硬件设计限制,CPU的加载、存储单元和指令获取不能直接访问内存,需通过L1 Cache。但L1 Cache容量有限,为提高速度,配置容量更大、速度稍慢、成本更低的L2 Cache和L3 Cache,最终通过L3与内存交互。这样设计是为减少CPU等待内存的时间,多层设计考虑了成本因素。CPU工作时,先判断访问内容是否在Cache中,有则为“Cache Hit”,可直接高速调用;无则为“Cache Miss”,需从内存读取。保证CPU处理内容尽量在Cache中,如操作连续物理内存地址的数据,可提高效率。为争取“Cache Hit”的连续性,适当增加内存使用和数据传递是可行的。
从叶劲峰 (Milo Yip) 在CGDC2015的讲座【为实现极限性能的面向数据编程范式】中可知,L1 Cache读取和内存读取速度差异大,“Mutex加锁/解锁”耗时17ns。在多线程并行开发中,减少锁竞争和资源锁定导致的其他线程查询等待消耗是关键问题。
至此,游戏引擎架构的CPU优化目标可确定为“充分利用多线程”、“尽量降低Lock Free的消耗”、“足够高的Cache hit”,这也是各大游戏厂商游戏引擎的研发方向。Cache和内存使用与数据结构设计相关,细节将在实际优化案例中阐述。以下是内存、Cache方面CPU优化的扩展阅读链接: Memory, Cache, CPU optimization links
接下来介绍“多线程”技术:
- 多线程渲染 Multi - thread Rendering:“多线程并发(Concurrency)的并行 (Parallelism) ”和“多线程渲染Multi - thread Rendering ”是不同概念。“多线程渲染”指多个线程同时提交Draw Call(Commands),写入GPU可识别的Command,以提交更多Draw Call。受硬件、驱动和操作系统历史因素影响,直到Windows 10 + D3D12才真正支持“多线程渲染”。在D3D9和D3D10中,只能一个线程调用图形API;D3D11的“多线程渲染”分出Immediate Rendering和Deferred Rendering,Deferred Context可记录多线程写入的命令,但实际推送由主线程的Immediate context完成;D3D12实现多个线程对应多个Command list写入指令和数据,并按指定顺序执行。移动平台上,GLES3.1和iOS的Metal API约为D3D11等级,之前的ES2.0和3.0与D3D9类似,目前难以实现真正的“多线程渲染”。在D3D12普及和对应引擎设计成熟前,多数采用专门的渲染线程独占一个CPU线程提交API指令,如CryEgnine、Unreal Engine、Unity3D等。以【《天涯明月刀》多线程渲染解决方案分享】为例,通过创建CommandRingBuffer,主线程更新物体对象后将数据和绘制命令添加到该Buffer,渲染线程从中获取命令执行,实现Render高效提交绘制命令。同时,为解决多线程共享内存的线程安全问题,可传入数据副本,天刀使用“GraphicAsyncObject”进行封装。
- 多线程的并发与并行:游戏中有大量物体对象需要Update,如动画、粒子、物理模拟和游戏逻辑处理等,若都在主线程进行,CPU会成为瓶颈,导致GPU等待。为充分利用CPU核心,游戏通常创建线程池,将一类物体对象的更新放到专用线程,但分担不均,仍有空闲;或一个进程运行多个线程,但需考虑线程切换开销和锁竞争等问题。因此,需要设计一种线程使用更充分、平均,锁竞争和线程切换开销更小的方案。
为更好理解,先对比两种锁机制Spinlock和Mutex:
- Mutex(或Semaphore):属于sleep - waiting类型,通过Critical Section方式实现。如双核CPU上,线程A获取锁时发现被线程B持有,则进入内核态Sleep,进行昂贵的系统调用和上下文切换,将A放入等待队列,运行线程C任务。
- Spinlock:属于busy - waiting类型,用Atomic flag方式实现,可理解为While循环获取锁,无线程状态切换,速度快(若Mutex延迟17ns,Spin约1 - 2ns),但会一直占用CPU,锁竞争激烈或临界区代码过长时性能严重下降,且不能进行IO系统调用。
一般复杂应用场景选择灵活的Mutex,优化时选择Spinlock。多线程并行的极致是尽量减少锁的使用,实现无锁并行,并根据用例测试选择合适的锁方案。
以下是几个厂商分享的解决方案:
- DICE的寒霜(Frostbite)引擎:2010年前后,EA的DICE工作室通过EA JobSystem实现Job - based Parallelism,将CPU系统工作分割为Job(Tasks),每个Job约15 - 200K(行数)的C++代码(平均25K),根据Job的依赖关系和同步点动态生成Job Graph,进行并行计算。该设计可充分利用处理器资源,对Cache Hit有帮助,但相关PPT信息少,执行在PS3家用机的CELL处理器的SPU上。
- 顽皮狗(Naughty Dog):在2015的GDC上,Naughty Dog分享了Fiber Job System,实现灵活、无竞争锁和线程切换的多线程并行解决方案。该系统对应X86的PS4家用机平台,有6个工作线程,每个线程锁定一个CPU核。Fiber作为小上下文在工作线程上执行,切换开销小。Job在Fiber的Context中执行,可yield等待其他Job结果,放入不同优先级的Job Queue。游戏中大部分处理为Job,如物体对象更新、动画更新等。实际执行时,根据优先级从Job Queue选择Job放入Fiber上下文,在Worker Thread上执行。该系统采用Atomic spin locks,速度快,但spinlock的缺点可能引发问题,部分需要Mutex的地方用Sleep替换。
- 网易的Messiah(弥赛亚)引擎:Messiah面向手游开发,沿用部分已知技术,如使用CommandRingBuffer,锁定一个CPU核心作为渲染线程,将剩余CPU核心锁定为工作线程,将引擎工作分割为Job(Task),Task无需担心锁问题。创新之处在于参考boost::asio::strand,设计任务调度器(Task Scheduler),通过Task派发策略保证无锁情况下的线程安全,根据具体情况选择Spinlock或Mutex。调度器考虑Cache Hit和Task亲缘性,实现task stealing,保证渲染Command buffer的执行顺序。该方式降低并发代码设计维护成本,但多线程调试困难,且iOS设备和Android设备的并行效果差异不明。Messiah配套的基于GPU Bake lightmap以及GPU压缩PVR/ETC等功能,对美术资源迭代开放测试有帮助,其实际情况有待发布的游戏和技术分享确定。
小结:用大量篇幅介绍与Unity无关的多线程技术,旨在让开发者了解Unity在CPU优化上的不足,这也是自研引擎的优势。同样设备下,并行效率高的游戏引擎可能运行更多Draw Call,还可针对不同游戏类型定制。在使用Unity开发时,应考虑这部分差距,加强CPU优化。多线程优化后续不再提及,希望开发者在实际开发中体验和测试,获取合适方案。若多线程并行难以优化,接下来将尝试在3D API上进行优化。
3D API CALL的优化
国内多数游戏程序员对3D API,特别是移动终端的GLES2.0/3.0(与D3D9平级)较为熟悉,具体将结合后续案例讨论。“NGAPI(Next - Generation Graphics API)”技术尚不成熟,受移动终端硬件设备和操作系统版本限制,普及可能需数年时间。以下是SIGGRAPH2015上的相关链接,有兴趣可浏览:链接
Unity中显示的渲染状态仅包含Draw Call数量、CPU和GPU执行时间,不足以作为API Call性能参考。Unity的Profiler主要显示Unity的API调用,无法查看实际3D API CALL。以高通的Adreno Profile截取应用宝上一款游戏的一帧为例,一个Draw Call内部包含设置绘制状态、生成绑定顶点缓冲、选择Shader Program、设置Shader变量、绑定贴图等API调用,评估游戏CPU绘制消耗时,应考虑每个DC内部的实际API Call。Unity本身实现了一些标准的API Call优化,如Editor中的Static Batch和引擎内部的排序策略、状态对比等,以减少API调用次数。节省API Call还需结合每个API的CPU消耗时间,对每个DC进一步优化和合并。
GPU 优化基础
为确定GPU优化目标,列出Apple几款iOS设备和PC上主流GPU的参数,主要性能点包括内存带宽(Bandwidth)、填充率(Fill Rate)和浮点计算能力(FLOPS)。
- 像素/纹素填充率(Fill Rate):指每秒绘制到屏幕上的像素数量,向FrameBuffer输出也占用填充率,单位为MegaPixels/Second或GigaPixels/Second。例如,ipad air2的填充率是7.6 GigaPixes。填充率计算公式为:fill_rate = resolution depth_complexity frame_rate,其中resolution为设备屏幕分辨率,depth_complexity为深度复杂度(一帧中像素的绘制次数),frame_rate为帧率(FPS)。如分辨率为1024x768、深度复杂度为3、帧率为60fs的应用,填充率为(1024 x 768) 3 60 = 141.6 Mpixel/sec。Unity和一些分析工具提供OverDraw的预览和平均值。
- 内存带宽(Bandwidth):使用范围广泛,如Framebuffer读写、Shader读取Texture、ZBuffer读写等,单位为GB/sec。不同操作的内存带宽计算公式如下:
- Frame_buffer_bandwidth = resolution depth_complexity Frame Buffer Color Depth frame_rate z - buffer_pass_rate
- Texture_memory_bandwidth = resolution depth_complexity 2.5 Texel Color Depth frame_rate
- Z_buffer_bandwidth = resolution depth_complexity frame_rate 1.5 z_buffer_size
假设分辨率为1024x768、帧率60、深度复杂度为3、50%被z - buffer test pass掉,则Framebuffer带宽使用为(1024 768) 3 4 60 0.5 = 0.28 GB/sec;假设像素从内存fetch 2.5个texel,Texture总的内存带宽使用为(1024 768) 3 2.5 4 60 = 0.47 GB/sec;假设50%被pass掉,Z_buffer_bandwidth = 0.85 GB/sec;总内存带宽使用为2.55 GB/sec。
- Shader的每秒浮点计算力:单位一般为GFLOPS,通常不在Pixel Shader中写循环或大量分支判断,一般不会有问题,细节将在具体优化中讨论。
总结
因时间仓促,未能在1周内完成本篇全部内容,且上半部分Unity相关内容较少,与标题不太契合。但图形优化所需的CPU和GPU知识已涵盖,虽较基础。后续Unity的分析和优化方案将把具体表现转化为CPU或GPU消耗成本,进行针对性修改。后续优化分析案例将引用本节基础内容,不看影响不大。希望为对优化感兴趣但未入门的朋友提供帮助,也欢迎大神提出宝贵意见。后续性能分析和瓶颈判断部分将准备更多测试案例,尽快完成下篇分享。