序
随着当前越来越多的手游向“3A”靠拢,手机上的各种性能优化也在努力地为“3A”保驾护航,恨不得要把芯片上每一个晶体管的性能都挖掘出来。但是,当一台“高分低能”的手机摆在你面前的时候,是不是总是有一种“欲哭无泪”的无力感——既要保持高帧率又要保证画面质量。成年人从来不做选择题,在两个都要的情况下,降分辨率往往是起效最快的办法。
说到调整设备的分辨率,Screen.SetResolution这个方法大家肯定是很熟悉了,但是这种调整是全局的,是硬件级别的调整,无法做到3D和UI渲染目标的分开调整。当然,随着SRP管线的推出,我们已经可以实现3D相机和UI相机分辨率的分开调整,并且UWA上已有相关文章的介绍了(见参考9)。
今天这篇文章要探讨的是,Unity和Unreal都提供的动态分辨率的方案,它可以动态缩放单个渲染目标,以减少GPU上的工作量。
说到3D和UI的分开渲染,聪明的小伙伴肯定想到了一种方案:3D渲染到一张RT上,最后把3D的RT Blit到最终的RT上。那么这种方案跟Unity提出的动态分辨率方案有何不同的地方吗?还是说只是新瓶装旧酒?
接下来,我就跟大家一起探索一下动态分辨率(以Unity为主)的原理以及它的应用场景。
传统的3D和UI分离方案
如上图所示,基本原理是渲染场景的时候调整视口大小(Viewport),将渲染约束到屏幕外Render Target的一部分,然后再把场景的Render Target上的内容Blit到最终的RT上。例如,渲染目标的大小可能为(1920,1080),但视口的原点可能为(0,0),大小为 (1280,720)。
这种实现方式可能会有如下几个问题:
- Blit的性能损耗,这个操作肯定不能是实时的,一般也就是在游戏初始化后或者在进入某个场景前设置一次,是一个低频操作,无法做到真正的“实时”调整。
可能受限于渲染管线
- 如果是默认渲染管线的话,最后这个Blit的操作时机就要选好,因为游戏中一般会有后处理阶段,我们要利用好这个阶段顺便把Blit也做了。这个可以利用CommandBuffer向相机的不同渲染阶段插入视口修改和后处理操作。
- 如果是SRP渲染管线的话(Unity 2018以后的版本),我们就能有自己处理Blit的时机了,当然这个操作也不能是个高频操作。
使用流程
参考Unity官方文档,我们先来看一下动态分辨率的使用流程。
首先我们要确认一点:动态分辨率启用的前提是GPU Bound了。所以要通过实时获取每帧GPU的运行时间来决定:
- 是否是GPU压力过大导致游戏掉帧
- 渲染目标的缩放系数
再根据缩放系数对渲染目标进行动态缩放。在这个过程中需要保证修改渲染目标分辨率的时候不重新分配GPU显存,否则就跟Screen.SetResolution一样了(会导致画面闪烁)。
- 在需要动态缩放的相机上勾选,如图所示:
- 在PlayerSettings中勾选上“Enable Frame Timing Stats”:
- 通过两个接口FrameTimingManager.CaptureFrameTimings()和FrameTimingManager.GetLatestTimings获取CPUTime和GPUTime后自行判断缩放系数
- 最后调用ScalableBufferManager.ResizeBuffers(m_widthScale, m_heightScale)设置缩放
平台支持
可能跟理解水平有关系,看到以上的说明,我就犯迷糊了:OpenGLES不支持动态分辨率,内置渲染管线、URP等兼容,那么如果是URP下的OpenGLES平台呢?支持还是不支持?
不管如何,先把疑惑放一边,我们来探究一下动态分辨率的实现原理。
原理探究
我们顺着官方文档上的使用流程,摸入Unity源码内部,看看为什么对OpenGLES如此厚此薄彼。因为涉及到源码部分,这里就直接说结论了。
- 缩放RT是跟平台相关的,OpenGLES无法创建缩放RT,原因我们后面再讲
- 动态分辨率的原理为Vulkan的内存混叠(Memory Aliasing)功能
Memory Aliasing
Memory Aliasing可以翻译成内存混叠或内存别名,参考[1]是Vulkan针对此概念的说明。
现代图形 API(如DirectX 12或Vulkan)可以让用户定义内存位置,将分配的GPU资源放入手动创建的堆中。它允许我们创建纹理和缓冲区,它们的内存部分甚至可以完全重叠。这也是为什么OpenGLES不支持动态分辨率的原因,因为OpenGLES没有开放更底层的API让我们可以实现更高效的内存管理。
以游戏中典型的一帧为例:光栅化一些几何体,执行着色,然后运行一堆后处理。这里的每个阶段的输出都将写入纹理或缓冲区,稍后在一帧中被其他阶段使用。但是,某个阶段产生的资源可能只被少数其他阶段使用,比如在后处理中:Bloom产生的输出,只会被下一阶段的Tone mapping(色调映射)使用,并且在帧中的其他任何地方都不需要。我们可以看到,资源的有效生命周期可能很短,但很可能是预先分配的,并且在整个帧中都占用了它的内存。
解决内存频繁分配释放的方法就是对象池,Unity的RenderTexture.GetTemporary就是在内部维护了一个RenderTexture的对象池。但是这种方法只适用于后处理阶段,因为不同格式、大小的资源不能复用,后处理通常是全屏的Pass,读取、写入的Texture通常都有相同的属性,一些简单的后处理只需要两个RT反复交替使用就能实现(这个我会在稍后的URP章节中重点解读一下) 。
对象池本质上是一种更上层的Memory Aliasing,开发者不需要关注内存管理;但现代图形API(DX12和Vulkan)提供了内存管理的接口,可以实现底层的Memory Aliasing。Memory Aliasing指的是不同变量指向同一地址,即在同一片内存区域中同时存放多个资源,如果有很多大型资源在时间上不会重叠,就可以在相同的内存分配这些资源。相比对象池,Memory Aliasing可以进一步降低内存占用,因为在底层都是一堆字节,所以就不需要考虑资源的类型、格式、大小等。具体的示意如下图所示:
小结
从以上的分析我们大概了解到了Unity实现动态分辨率的原理:利用Vulkan提供的内存管理接口,实现底层对内存高效地复用。这样我们在游戏中就可以高效实时地调整分辨率,基本没有性能损耗。
URP实现
考虑到URP的前身LWRP还有项目组在用,下面先简单看一下LWRP。
LWRP
简单点说就是通过重新创建相机的渲染目标来实现的。Setup时会先进入函数RequiresIntermediateColorTexture判断是否要创建新的RT,里面就有个变量isScaledRender,如果需要缩放,则进入创建RT的Pass:
m_CreateLightweightRenderTexturesPass
public void Setup(ScriptableRenderer renderer, ref RenderingData renderingData)
{
...
bool requiresRenderToTexture = ScriptableRenderer.RequiresIntermediateColorTexture(ref renderingData.cameraData, baseDescriptor);
RenderTargetHandle colorHandle = RenderTargetHandle.CameraTarget;
RenderTargetHandle depthHandle = RenderTargetHandle.CameraTarget;
if (requiresRenderToTexture)
{
colorHandle = ColorAttachment;
depthHandle = DepthAttachment;
var sampleCount = (SampleCount)renderingData.cameraData.msaaSamples;
m_CreateLightweightRenderTexturesPass.Setup(baseDescriptor, colorHandle, depthHandle, sampleCount);
renderer.EnqueuePass(m_CreateLightweightRenderTexturesPass);
}
...
}
public static bool RequiresIntermediateColorTexture(ref CameraData cameraData, RenderTextureDescriptor baseDescriptor)
{
if (cameraData.isOffscreenRender)
return false;
bool isScaledRender = !Mathf.Approximately(cameraData.renderScale, 1.0f);
bool isTargetTexture2DArray = baseDescriptor.dimension == TextureDimension.Tex2DArray;
bool noAutoResolveMsaa = cameraData.msaaSamples > 1 && !SystemInfo.supportsMultisampleAutoResolve;
return noAutoResolveMsaa || cameraData.isSceneViewCamera || isScaledRender || cameraData.isHdrEnabled ||
cameraData.postProcessEnabled || cameraData.requiresOpaqueTexture || isTargetTexture2DArray || !cameraData.isDefaultViewport;
}
URP
从Unity 2019.3.0a这个版本开始,LWRP开始正式升级为URP。URP主要分为两个文件夹:一个是单独提取出来跟HDRP共用的基础核心库core,另一个就是URP自己用的universal。
翻看了URP各个版本的代码,直到Core RP库10.2版本(对应Unity版本为2020.2.0b)开始,Unity才开始重视(提供)Render Target(渲染目标)的管理功能。
从上一章节的“原理探究”中,我们知道渲染目标管理是任何渲染管线的重要组成部分;我们也知道RenderTexture只有在新渲染纹理使用完全相同的属性和分辨率时才能重用内存。
为了解决渲染纹理内存分配的这些问题,Unity的SRP(URP&HDRP)引入了RTHandle系统。该系统是RenderTexture之上的一个抽象层,可较好地管理渲染纹理,具体介绍可以看参考8,这里我就简单介绍一下。
如上截图中枚举所示,SRP实现了“硬件”和“软件”两种动态分辨率,“硬件动态分辨率“就是利用内存混叠硬实现的,而”软件动态分辨率“就是缩放RT适应当前视口的软实现。当硬件动态分辨率不支持当前平台时,RTHandle系统会自动切换为软件动态分辨率。不仅如此,最新的URP版本还基于RTHandle实现了双缓冲,感兴趣的可以去URP源码查看RenderTargetBufferSystem。
应用
一路下来,我们对“动态分辨率”也有了一个比较深刻的认识了,当说到“动态分辨率”时,我们说的就是真正的硬件层面实现的动态分辨率,即:能够充分利用现代图形API的Memory Aliasing,为把FPS维持在一定的水平,当发生GPU引起的掉帧时,能够在不重新分配GPU显存(利用图形API的Memory Aliasing)的情况下动态调整渲染目标分辨率。
但是,考虑到设备的兼容性,我们大部分游戏支持的平台都只能是OpenGLES而不是Vulkan,因此很遗憾,动态分辨率派不上用场了。退而求其次,针对不同的渲染管线,下面简单说明一下我们能够采用的方案:
- 默认渲染管线——Unity 2017(含)以前的版本
- 可以使用本文“传统的3D和UI分离方案”中介绍的方案,利用CommandBuffer在合适的时机对视口进行动态调整,但不能高频使用。
- LWRP——Unity 2018~Unity 2019.3.0a
- URP——Unity 2019.3.0a12+~Unity 2020.2.0b8+
LWRP作为URP的前身,有好多功能还在完善中,已经可以比较好地实现3D和UI的分开渲染,相比默认渲染管线灵活性更好了。但还是没有提供比较好的RT管理,需要自己参考URP来定制一套高效的RT管理系统。
- URP——Unity 2020.2.0b12+
如上所述,直到SRP的Core RP库10.2版本开始,Unity才提供了一套比较完善的RT管理系统,大家可以酌情参考使用。
参考
[1] https://www.khronos.org/regis...
[2] 内存混叠的一种实现
[3] https://developer.nvidia.com/...
[4] https://docs.unrealengine.com...
[5] https://www.intel.com/content...
[6] https://docs.unity3d.com/Manu...
[7] https://github.com/Unity-Tech...
[8] https://docs.unity3d.com/Pack...
这是侑虎科技第1198篇文章,感谢作者吕强供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)
作者在UWA学堂上线的《五天实现PBR保姆级教程》课程限时优惠中~
再次感谢吕强的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)