专栏名称: CG世界
CG世界是CG领域自媒体。专注3D动画、影视特效后期制作、AR/VR几个领域的知识,前沿技术,资讯和行业教程分享。
目录
相关文章推荐
比亚迪汽车  ·  汉家族智驾版|泊车一步到位 ·  昨天  
小米汽车  ·  小米汽车答网友问(第108集) ·  17 小时前  
TGB湖南人  ·  【2.24复盘】震荡和回踩是为了更好的上涨, ... ·  22 小时前  
小米汽车  ·  2月27日晚7点,双Ultra,联袂登场巅峰 ... ·  昨天  
比亚迪汽车  ·  国货之光·悦己宜家 | 一图看懂方程豹 ... ·  3 天前  
51好读  ›  专栏  ›  CG世界

游戏引擎与着色器卡顿:虚幻引擎的解决方案

CG世界  · 公众号  ·  · 2025-02-23 10:26

正文

点击上方蓝字 CG世界 关注我们
再点右上角···设为 星标★


前言

大家好,我们是Kenzo ter Elst、Daniele Vettorel、Allan Bentham和Mihnea Balta,我们是虚幻引擎PSO预缓存系统的工程师。
最近,Epic社区有许多讨论都谈到了着色器卡顿问题以及它对游戏开发项目造成的影响。
今天我们将深入探索为什么会发生卡顿,并详解PSO预缓存如何帮助你解决这类问题,同时还会了解一些开发环节中的常见做法,帮助你最大限度地减少着色器卡顿。我们还会分享PSO预缓存系统未来的计划。
如果你还想进一步了解这种技术,千万不要错过我们本周三(2月7)凌晨3点在Twitch或YouTube上的直播。
背景
当渲染引擎发现在绘制时需要编译新的着色器时,就会出现着色器编译卡顿的问题,这时所有东西都会停下来等待驱动程序完成编译。要理解为什么会发生这种情况,我们需要先深入了解着色器怎么被转换成在GPU运行的代码。
着色器是在GPU上执行的程序,并会在渲染3D图像时处理多个步骤,比如处理变换、变形、阴影、光照、后期处理等等。通常这些步骤会使用高级语言编写(例如HLSL),所以必须编译成机器码才能在GPU上运行。这个过程其实和CPU的类似,高级语言编写的代码(例如C++)会被送入编译器,然后才能在特定架构(比如x64、ARM等等)中生成指令。
但是这两者又有一个关键的区别:每个平台(PC、Mac、安卓等等)通常只有一套或两套CPU指令集,但GPU型号多种多样,因此指令集也是五花八门。一个十年前给x64 PC编写的可执行程序也能在AMD或英特尔的CPU上跑起来,因为两家用的都是同样的指令集,而且都具有强大的向下兼容性。而给AMD GPU编写的二进制程序就无法在英伟达的GPU上生效,反之亦然。甚至同一个厂商生产的不同型号的硬件所使用的指令集都会有所差别。
因此,虽然我们可以将CPU程序直接编译为可执行的机器码并完成分发,但对GPU程序则必须采取不同的方法。高级着色器编码会被编译成中间表示或字节码,它们会使用3D API定义的抽象指令集,比如Direct3D 11的DXBC、Direct3D 12的DXIL以及Vulkan的SPIR-V等等。
游戏会发布这些字节码二进制文件,这样就只会有一个统一的着色器库,而不是给每种可能的GPU架构分别提供不同的库。在运行时,驱动程序会将字节码转换成设备中安装的GPU的可执行代码。CPU程序有时也会用到这种方法。比如Java源码也会被编写成字节码,这样同一个二进制文件就能在所有包含Java环境的所有平台上运行,无论设备的CPU是什么。
这个系统刚推出的时候,游戏的着色器还相对简单,数量也不多,而且从字节码转换为执行码的过程也简单直接,所以运行时的开销几乎可以忽略不计。随着GPU的性能越来越强大,我们的着色器代码越来越多,驱动程序也开始处理更加复杂的转换来生成更高效的机器码,这就导致运行时的编译开销问题越发严重。这个问题在Direct3D 11彻底爆发,所以现在的API(例如Direct3D 12和Vulkan)推出了管线状态对象(PSO)这个概念,旨在解决此类问题。
管线状态对象
通常渲染一个对象会用到多个着色器(例如一个顶点着色器和一个像素着色器协同工作),还需要一系列其他GPU相关的设置,例如剔除模式、混合模式、深度模具比较模式等等。这些会共同定义GPU渲染管线的配置,或者说“状态”。
老版本的图形API(例如Direct3D 11和OpenGL)可以在任意时间单独更改部分状态,也就是说驱动程序只会在游戏发出绘制请求时才能看到完整的配置。有些设置会影响到可执行的着色器代码,因此许多情况下驱动程序只会在处理绘制指令时才开始编译着色器。这样就可能耗费几十毫秒甚至更长时间才能完成一个绘制指令,导致第一次使用着色器时帧率会大幅降低,也就是玩家常说的“掉帧”或者“卡顿”。
现在的API会要求开发者将绘制请求中所有会用到的着色器和设置都打包到管线状态对象中,并把它设为单个单元。最关键的是,PSO可以在任何时候构建,所以从理论上来说引擎可以有相对充足的时间(比如在加载的时候)提前创建所需的所有内容,这样编译就可以在渲染之前完成。
理论与实践
虚幻引擎强大的材质创建系统让美术师们可以打造出丰富多彩、引人入胜的场景,许多游戏都已经包含数千种材质。每个材质又会产生许多不同的着色器,例如一个材质在静态/蒙皮和样条网格体上渲染时都有分别独立的顶点着色器。同样的顶点着色器有可能会和不同的像素着色器一起使用,这又会因为不同的管线配置设置导致出现更多的集合。因此就需要先编译数百万种不同的PSO才能确保覆盖所有可能出现的集合,这样显然从耗时和内存角度来说都是难以接受的(加载一个关卡就要花费几个小时)。
在运行时,真正会用到的PSO只会是这些海量子集中极小的一部分,可如果只孤立地去分析单个材质,根本就无法确定哪些子集会被调用。而这些子集又会因为不同的游戏会话而发生变化:调整游戏的画面设置可能会影响到特定的渲染功能,导致引擎使用不同的着色器或管线状态。在早期Direct3D 12的引擎实践中,我们会通过游玩测试、自动化关卡遍历以及其它类似手段来了解情况,记录实际会使用的PSO。这些数据也会被纳入到最终游戏中,用来在游戏启动或加载关卡时创建已知的PSO。虚幻引擎把这种叫做“捆绑式PSO缓存(Bundled PSO Cache)”,在UE 5.2之前一直是我们推荐的最佳实践方案。
这种捆绑式缓存足够处理许多游戏,但也有诸多局限。毕竟收集这些数据是一项资源密集型的工作,而且如果内容出现变化,还必须保持更新。如果游戏中有高度动态的场景,那记录过程中可能无法发现所有的PSO:例如对象的材质可能因为玩家的行为而出现变化。
如果游戏会话中有大量变体内容,那么实际缓存可能远远超出预计,例如游戏内有大量地图,或者玩家要从大量皮肤中做出选择。比如《堡垒之夜》就不适合这种捆绑式缓存,因为会遇到诸多限制。而且《堡垒之夜》中有许多用户生成的内容,所以需要根据各个体验来做PSO缓存,将收集缓存的部分交给内容创作者。

PSO预缓存
为了支持庞大且多样的游戏场景和用户生成内容,虚幻引擎5.2推出了PSO预缓存,该技术可以在加载中确定可能的PSO。当加载一个对象时,系统会分析对象的材质,并结合网格体信息(例如静态与动态)以及全局状态(例如画面品质设置),计算出渲染该对象时可能需要的PSO子集。
虽然这样的子集依然会比实际会使用的要大,但也已经比之前全部覆盖的小多了,所以可以用于在加载时编译。例如《堡垒之夜》大逃杀模式中,一场对局就要编译30000个PSO,其中会使用的大概有10000个,但这也只是所有组合池(可能有几百万个)中非常小的一部分。
对象会在加载地图时创建,并且在显示加载界面时预缓存相应的PSO。在游戏中流送或生成的内容既可以等待自己的PSO准备完成再进行渲染,也可以使用已经编译完成的默认材质。在大多数情况下,这只会影响几帧画面的流送,几乎是无法察觉。这套系统基本解决了材质的PSO编译卡顿问题,并且可以和用户生成内容无缝配合。
在已经可见的网格体上更改材质会比较棘手,因为我们不想直接把它隐藏,或者在编译新PSO时用默认材质去渲染。我们正在开发新的API,让游戏代码和蓝图会提前告知系统,从而更早地预缓存额外的PSO。我们还希望能够改进引擎,让它能在编译新材质时保持对之前材质的渲染。
虚幻引擎内设有一类与材质无关的独立着色器。它们被称为全局着色器,这些程序可以被渲染器用来实现各种算法和效果,比如动态模糊、超分辨率、降噪等等。这种预缓存机制也能覆盖全局计算着色器,不过UE 5.5尚未支持全局图形着色器。然而这些PSO在首次使用时仍然会有极小概率导致出现一次性的卡顿。我们正在努力解决这类预缓存覆盖中仍然存在的问题。
捆绑式缓存可以和预缓存配合使用,这在在某些游戏中会非常好用。你可以在捆绑式缓存中包含部分通用材质,这样就可以在游戏启动时进行编译,而不是在游戏过程中编译。这样也能更好地处理全局图形着色器,因为你可以在检测过程中遇到并记录它们。
驱动缓存
驱动程序可以将编译后的PSO保存到硬盘,这样后续的游戏过程中再遇到的时候就可以直接加载。这种方法适合使用各种引擎以及PSO编译策略的游戏。而对于使用PSO预缓存的虚幻引擎项目,这种方法可以让第二次运行时加载的时间大幅缩短。如果驱动缓存是空的,那《堡垒之夜》加载一场大逃杀对局可能需要20到30秒,甚至更长。安装新的驱动程序之后缓存会被清空,这就是为什么你更新驱动程序之后,第一次进游戏的加载时间会更长。
虚幻引擎会在加载期间创建PSO并在完成编译后立即舍弃,从而充分利用驱动缓存的优势——所以我们把它叫做“预缓存”。当后续渲染中需要使用某个PSO时,引擎会发送编译请求,而驱动程序会直接从缓存中返回结果,因为它就在预缓存系统里。这个PSO被用于绘制之后,它就会保持加载状态直到场景中所有会用到它的图元都被移除,这样就不用每帧画面都询问驱动程序了。
在预缓存后进行舍弃就避免了让不会使用的PSO占用内存。不过缺点就是当你需要它的时候,又要从驱动缓存中获取PSO,虽然这比编译要快得多,但仍然需要花费一定的时间,导致第一次渲染材质时出现轻微的卡顿。
一个简单的解决方案就是保留PSO,但这样会占用超过1GB的内存,因此仅适用于内存充足的设备。我们正在研究新的解决方案,既能减少对内存的占用,同时还能自动判定预缓存的PSO是否需要保留。
一般只有部分状态会影响可执行的PSO代码。这就意味着如果我们创建了两个有同样着色器但管线设置不同的PSO,那只有第一个需要经过耗时的编译过程,第二个就能直接从缓存中返回结果。
但实际情况是,影响代码生成的状态集合在不同GPU中也会有差异,甚至在不同的驱动程序版本中也会各有不同。虚幻引擎根据实际操作的经验,可以在预缓存过程中跳过部分排列组合。驱动缓存可以缩短冗余请求花费的时间,但引擎仍然不得不生成这些请求。而且这些工作量会积累起来,因此我们更需要精简流程,减少加载次数和内存占用。
移动平台与主机
移动平台使用的是相同的设备着色器编译模型,所以虚幻引擎的预缓存系统也同样有效。简而言之,移动端渲染器用的着色器比桌面端少,但由于CPU速度更慢,PSO编译花费的时间往往更长,因此我们对移动端的流程做出了调整。
我们跳过了部分基本上不会用到的排列组合,这样预缓存的集的覆盖范围就不会那么大,这也导致在某些情况下要渲染某个不常见的状态时会出现卡顿的情况。我们为地图加载期间的预缓存设置了时间限制,防止长时间显示加载界面。这也意味着游戏会在编译任务尚未全部完成的情况下开始,如果需要立即处理某个PSO就会出现卡顿。我们采用了优先级提升系统,当需要某个PSO时,系统会将相应的任务提到队列前端,尽可能减少卡顿情况。
主机平台则不需要面对此类问题,因为它们都有单一目标GPU。单个的着色器会直接编译为可执行代码并随游戏一同发布。当使用相同的顶点着色器配合多个像素着色器或处理多个管线状态时,系统并不会进行重新编译,所以不会出现组合爆炸的问题。着色器和状态可以在运行时一同进入PSO,并且不会产生大量的开销,所以主机平台并不会出现PSO卡顿。
要回归Direct3D 11?
目前有一种误解认为Direct3D 11不存在以上这些问题,我们偶尔也会听到有人呼吁要回归老的编译模型甚至用老的图形API。就像上文解释的那样,过去也有卡顿的问题,而且由于当时API设计的局限,引擎没有任何办法避免这些问题。它们看起来出现的频率更低或者用时更少,主要是因为以前的游戏更加简单,使用的着色器较少,而且当时也没有光线追踪。
驱动程序也做了很多努力来尽可能减少卡顿问题,但也无法做到完全避免。在问题进一步恶化之前,Direct3D 12试图通过PSO解决这个问题,但引擎需要时间来更高效地运用PSO,部分原因是改造现有的材质系统困难重重,还有部分原因是API本身的缺陷,并且随着游戏变得越来越复杂而更加突显。
虚幻引擎作为通用型引擎,用途广泛且已经形成固定的内容和流程,也使得这类问题显得更加棘手。我们也终于找到了可行的解决方案,并且也有相应的方法能够有效弥补API的缺陷,比如Vulkan扩展图形管线库。
再接再厉
自虚幻引擎5.2版本推出预缓存实验性功能以来,它已经取得了长足的发展,并能防止绝大多数的着色器编译卡顿。但目前仍然存在覆盖缺陷和其它限制,我们将继续努力,不断改进。我们也在与硬件和软件厂商合作,让驱动程序和图形API能够更好地适配当前游戏使用此类系统的实际情况。
我们的最终目标是实现预缓存的自动化和最优化,让游戏开发者不用再花费精力去防止出现卡顿问题。在这套系统尽善尽美之前,我们的授权用户还可以通过以下方式确保流畅的游戏体验:
  • 使用最新版本的引擎。 由于预缓存功能仍在开发中,所以更新版本的引擎的表现会更好。如果你无法实现全面升级,那也可以将大部分改进向下移植到你当前的自定义引擎中。






请到「今天看啥」查看全文