大型项目中 MSAA 的方案参考

一、MSAA 简介

关于锯齿的产生原因以及主流抗锯齿技术 MSAA 网上的资料很多,凡是游戏开发也多多少少都有了解,因此这里就不多赘述,有兴趣可以直接参考以下几篇文章:

  1. 现代图形 API 的 MSAAUE4 MSAA & depth
  2. 知乎上关于 MASS 和抗锯齿的问题 & 解答
  3. 最简单的 OpenGL 抗锯齿主流抗锯齿方案详解

拿随意一个游戏举例,MSAA N samples 效果对照如下:

1.1 移动平台上的 MSAA

前面介绍了锯齿的产生原因以及 MSAA 解决方案,这里主要是介绍 MSAA 每一步是在哪个时机,那一块地方做的,简单描述下性能上的问题,并且主要考虑移动平台的 TBRD 架构

一样可以先参阅文章:

  1. 深入剖析 MSAA
  2. 针对移动端 TBDR 架构 GPU 特性的渲染优化TBDR 架构基础

1.1.1 关于流程

直入主题:MSAA 先在光栅化阶段生成覆盖信息,然后计算像素颜色,根据覆盖信息和深度信息决定是否来写入子采样点,整个完成后再通过某个过滤器进行降采样得到最终的图像,大体流程如下图:

极大部分情况下片上的 FrameBuffer 是 NxMSAA 格式,而我们只需要最后 MSAA resolve / 降采样的结果:此时硬件及 API 就会最直接在片上就完成 resolve 操作,这是最理想情况,也是 On-Chip MSAA 的规操:只要你当前的 RenderTarget 是单一采样格式

//像 Unity 中我们自己写的 RenderPass,需要 or 写入的 RT 都是不开 MSAA 的
rtDescriptor = new RenderTextureDescriptor(width, height, format, depthBufferBits)
{
    dimension = TextureDimension.Tex2D,
    msaaSamples = 1,
    sRGB = false
};

Unity FrameDebug 也可以跟踪到每次 MSAA resolve 的时机:

1.1.2 看上去硬件包办了,但还远没有这么简单

通过前面的流程也能知道:因为有硬件支持,多 Samples 的纹理存储以及 resolve 操作都是在片上做的(On-Chip),也就是红色箭头的部分,因此和 Depth Test、Alpha Test 类似,MSAA 只需要跟片上缓存交互即可,这样直接避免了 GPU 内存的直接读写,降低了带宽消耗

由于 GPU 的片上缓存的存储空间非常有限,因此渲染完成一个 Tile 之后,需要将结果复制到FrameBuffer 中(#Unity RenderBufferStoreAction),同理如果一帧内需要修改 RenderTarget 多遍渲染时,在对 Tile 进行写入的时候可能还需要从 FrameBuffer 中将对应 Tile 中旧的数据读取到片上缓存(#Unity RenderBufferLoadAction,对于 Tile 是 restore)

但上面都是理想情况,MSAA 的性能和各显卡平台支持程度都不容乐观,其中一点就是:像 4xMSAA 就需要四倍的块缓冲内存,考虑到芯片上的块缓冲内存很最贵,所以显卡会通过减少块的大小来消除这个问题,举个例子,假设默认的 tile 渲染大小是 32x32,如果你开启了 2xMSAA,如果没到内存瓶颈还好,一旦超过了片上内存能能接受的上限,一个 tile 就只能渲染 16x16 的区域了

不但如此,由于大型游戏后续效果处理对 depthTexture 的依赖,引擎底层 / 图形 API 支持不足,导致硬件 MSAA 没法在 On-Chip 上一口气做完,我们不得不手动进行额外的 Load/resolve 操作从而产生更多的额外开销,这块没明白没关系,后面在解决 MSAA depth resolve 问题时会具体提到

1.2 Unity URP 开启 MSAA

其实很简单,就是一个设置:

 然后想办法把它做成游戏时可以动态配置的形式:

static URPAssetRuntimeParams assetRuntimeParams;
public UniversalRenderPipeline(UniversalRenderPipelineAsset asset)
{
    //修改下 URP 源码……
    UniversalRenderPipeline.assetRuntimeParams.Init(asset);
}
static void InitializeStackedCameraData(Camera baseCamera, UniversalAdditionalCameraData baseAdditionalCameraData, ref CameraData cameraData)
{
    var assetRuntimeParams = UniversalRenderPipeline.assetRuntimeParams;
    if (baseCamera.allowMSAA && assetRuntimeParams.msaaSampleCount > 1)
        msaaSamples = (baseCamera.targetTexture != null) ? 
        baseCamera.targetTexture.antiAliasing : assetRuntimeParams.msaaSampleCount;
}

public bool IsOpenMSAA
{
    get { return _isOpenMSAA; }
    set
    {
        _isOpenMSAA = value;
        UserDataManager.SetData(IS_OPEN_MSAA, GLOBAL_SETING_GROUP, value);
        if (IsOpenMSAA)
            UniversalRenderPipeline.assetRuntimeParams.msaaSampleCount = 2;
        else
            UniversalRenderPipeline.assetRuntimeParams.msaaSampleCount = 1;
    }
}

但这只是开始,如果你是大型项目的话,很有可能会不得不面对三个问题:

  1. 所有用到 depthTexture 的渲染全部出错,例如后处理描边等
  2. Win + D3D11 是好的,但是各种手机/平台不能很好的支持 MSAA ?
  3. 性能?能更省一点嘛?

1.3 AlphaToCoverage 及 HDR resolve

参考文章:

  1. 知乎:为什么 Alpha to coverage 方法不需要排序
  2. 关于风格化云渲染的一些尝试
  3. Alpha To Coverage

对于 Alpha to coverage:如果像草和树等 AlphaTest 的物体本身 Texture 就做过边缘柔滑,再加上也都不是特别细(<1pixel),就没有必要开启 Alpha to coverage

Unity 中可以在 shader 中添加如下标签以开启 AtoC:

AlphaToMask On

对于 HDR resolve:如果不存在爆亮区域需要解决锯齿问题,就也可以不做考虑

二、URP MSAA 及其 Depth resolve 问题

无脑开启 MSAA 带来的一个必然结果是,凡是用到 depthTexture 的 Shading,可能无一例外全坏:比如基于深度检测的后处理描边等等

2.1 MSAA resolve

为什么会这种情况,要先从 MSAA resolve 的算法说起,对于颜色而言,resolve 算法必定是对多采样点进行平均:不然就不可能得到抗锯齿的效果

但是归限于硬件的一个因素:同一 RT 下 MSAA 时 Depth Stencil 也必须是 MultiSample 的,并且与 Color 的 Sample 数量相同,这样深度格式就也必须是 NxMSAA 的,尽管深度完全不需要抗锯齿

但是这不是导致问题的关键,关键在于:depthTexture 也采取了和 colorTexture 一样的 resolve 算法也就是平均,从而使得边缘深度信息完全出错(并且这样做还没有任何意义),这也是导致上面问题的主要原因

知道了这点之后,其实想解决就很简单:第一个想到的方法必然是修改 depthTexture 的 resolve 算法:由平均改为取最值,但很可惜,前面说过这块是硬件帮我们处理的,因而我们想要修改 resolve 算法,第一步只能从硬件平台 API 上下手

2.1.1 硬件 resolve 支持

先吹一波这篇文章,其实已经讲得很好了:从一个小 BUG 看 MSAA depth resolve,大致总结下:像 IOS metal 直接就支持我们使用自定义的 resolve 算法,并且使用起来非常简单,但除此之外特别是主流的 Andriod OpenGLES 3.1/3.0 都不能很好的支持,但也有解,比如使用 framebuffer fetch 扩展等等,但无一例外都需要动到引擎源码,能不能搞定还有另说(特别是 Unity)

2.1.2 软件 resolve

这样看来,在不动源码的前提下,我们能考虑的也是最简单的:就是跳过硬件 resolve:这其实很好办,如果理解了前面文章里的内容,就很容易想到其中一个方案:不进行解析,直接将渲染纹理绑定为着色器中的多采样纹理

if (m_ActiveCameraDepthAttachment != RenderTargetHandle.CameraTarget)
{
    var depthDescriptor = descriptor;
    depthDescriptor.colorFormat = RenderTextureFormat.Depth;
    depthDescriptor.depthBufferBits = k_DepthStencilBufferBits;
    depthDescriptor.bindMS = msaaSamples > 1 && (SystemInfo.supportsMultisampledTextures != 0);
    cmd.GetTemporaryRT(m_ActiveCameraDepthAttachment.id, depthDescriptor, FilterMode.Point);
}

这样我们就可以保证我们拿到的 depthTexture 都是 resolve 前的,可以直接进行深度采样,或是干脆自己 Copy 并手动 resolve(以下代码来源于 URP CopyDepthPass):这和 OpenGLES 3.1 及以上使用 texelFetch 函数指定 sampleIndex 获取对应 sample 的 color,然后进行自定义 Resolving 的操作一样,都是软 resolve 解决方案

 #define DEPTH_TEXTURE_MS(name, samples) Texture2DMS<float, samples> name
 #if MSAA_SAMPLES == 1
    DEPTH_TEXTURE(_CameraDepthAttachment);
    SAMPLER(sampler_CameraDepthAttachment);
#else
    DEPTH_TEXTURE_MS(_CameraDepthAttachment, MSAA_SAMPLES);
    float4 _CameraDepthAttachment_TexelSize;
#endif

float SampleDepth(float2 uv)
{
    #if MSAA_SAMPLES == 1
        return SAMPLE(uv);
    #else
        int2 coord = int2(uv * _CameraDepthAttachment_TexelSize.zw);
        float outDepth = DEPTH_DEFAULT_VALUE;
    
        UNITY_UNROLL
        for (int i = 0; i < MSAA_SAMPLES; ++i)
            outDepth = DEPTH_OP(LOAD(coord, i), outDepth);
        return outDepth;
    #endif
}

好了到此 depth resolve 问题就应该可以解决了,但软件 resolve 的代价呢?必然是有的:首先就是 nxMSAA 多倍的内存占用,这次直接给 load 到了内存中,光带宽问题就不小,毕竟你放弃了硬件的 resolve,同时也就放弃了 On-Chip MSAA

2.1.3 题外话:为什么都说延迟渲染(Deferred Rendering)不支持 MSAA

事实上,你可以查阅的大多数资料,对这一块的解释都是模棱两可的,并不完全正确

其实按照图形硬件及驱动发展的时间线,大致可以两个时间段:

  • DX9 / OpenGL 2.0 及之前:驱动和图形接口不支持 MRT MSAA,由于延迟渲染需要 MRT,因此自然就不支持 MSAA
  • DX10.1 及之后:MRT 支持 MSAA,在这种情况下延迟渲染理论可以支持 MSAA,只不过按照正常的思路,如果你要对 GBuffer 中的 ColorRT 开启 MultiSample,那么 GBuffer 中一次 MRT 的所有目标纹理(RT)都需要开启 MultiSample,这就意味着你不但要解决深度缓冲区的 resolve 问题,还要解决诸如法线、光照等多个额外纹理的 resolve 问题,又或者保留这些纹理中心点的采样结果,但是无论是怎样处理,直接带来的就是多倍的内存占用及带宽消耗,因此常规的 MSAA 方案在这里确实是用心无力

2.2 Unity-URP 是怎么解决的

URP MSAA 及两个 Depth Pass

一个前提是:如果你需要在 shader 里用到 depthTexture,那么就需要先勾选 DepthTexture 配置项:这样 URP 内部才会通过 CopyDepth 的方式获取当前摄像机的 depthTexture

参考对应的源码:

  • createDepthTexture 决定是否创建深度纹理,否则直接使用摄像机的 Target
  • 会有一个专门的 CopyDepthPass 负责深度的拷贝,并设置全局的 _CameraDepthTexture 以供 shader 使用
if (cameraData.renderType == CameraRenderType.Base)
{
    m_ActiveCameraColorAttachment = (createColorTexture) ? m_CameraColorAttachment : RenderTargetHandle.CameraTarget;
    m_ActiveCameraDepthAttachment = (createDepthTexture) ? m_CameraDepthAttachment : RenderTargetHandle.CameraTarget;
    bool intermediateRenderTexture = createColorTexture || createDepthTexture;

    // Doesn't create texture for Overlay cameras as they are already overlaying on top of created textures.
    bool createTextures = intermediateRenderTexture;
    if (createTextures)
        CreateCameraRenderTarget(context, ref renderingData.cameraData);
}
if (!requiresDepthPrepass && renderingData.cameraData.requiresDepthTexture && createDepthTexture)
{
    m_CopyDepthPass.Setup(m_ActiveCameraDepthAttachment, m_DepthTexture);
    EnqueuePass(m_CopyDepthPass);
}

2.2.1 当开启 MSAA 之后,URP 这么处理深度

目前的 URP 7.5.1 版本,如果开启了 MSAA,内部反而不会进行 DepthCopy,取而代之的是 DepthPrePass,也就是预渲染深度:

这个 pass 会在所有的物体真正绘制之前,先画一遍不透明物体,但是只会写入深度值(DepthOnly),并且目标纹理不会开启 MSAA,也和摄相机渲染目标完全无关,就是一个自己的 RT,这张 RT 就作为 DepthTexture 作为后续使用


// If camera requires depth and there's no depth pre-pass we create a depth texture that can be read later by effect requiring it.
bool createDepthTexture = cameraData.requiresDepthTexture && !requiresDepthPrepass;
createDepthTexture |= (cameraData.renderType == CameraRenderType.Base && !cameraData.resolveFinalTarget);
if (requiresDepthPrepass)
{
    m_DepthPrepass.Setup(cameraTargetDescriptor, m_DepthTexture);
    EnqueuePass(m_DepthPrepass);
}

很明显这样做不会出现 MSAA depth resolve 的问题,毕竟写入的 RT 压根不开启多倍 MSAA sampler,但是它需要你对所有不透明物体都多加一个 DepthOnly 的 Pass,这意味着会多出大量额外的 drawcall,尽管这个 pass 里面不需要任何计算

综上这个只能说是可行的方法之一:以更多 drawcall 的代价解决 MSAA 深度的问题,同时也可以顺带做下软件 earlyZ,不过这只是目前 URP 的做法,但显然不是唯一解

2.2.1 还有别的方案嘛

当然可以,不过要略微修改下 URP 的源码:那就是在渲染不透明物体后,执行 DepthCopyPass 时直接软件 resolve 深度

首当其冲:把最下面判断 DepthCopyPass 是否启用的逻辑中的 msaa 判断加回来:

bool CanCopyDepth(ref CameraData cameraData)
{
    bool msaaEnabledForCamera = cameraData.cameraTargetDescriptor.msaaSamples > 1;
    bool supportsTextureCopy = SystemInfo.copyTextureSupport != CopyTextureSupport.None;
    bool supportsDepthTarget = RenderingUtils.SupportsRenderTextureFormat(RenderTextureFormat.Depth);
    bool supportsDepthCopy = !msaaEnabledForCamera && (supportsDepthTarget || supportsTextureCopy);
    // TODO:  We don't have support to highp Texture2DMS currently and this breaks depth precision.
    // currently disabling it until shader changes kick in.
    depthDescriptor.bindMS = msaaSamples > 1 && (SystemInfo.supportsMultisampledTextures != 0) && !SystemInfo.supportsMultisampleAutoResolve;
    //bool msaaDepthResolve = false;
    return supportsDepthCopy || msaaDepthResolve;
}

但是只改这个的话,进游戏会出错,原因(报错内容)是:A non-multisampled texture being bound to a multisampled sampler. Disabling in order to avoid undefined behavior. Please enable the bindMS flag on the texture

字面意思:采样纹理格式和采样器对不上,因此还需要设置渲染纹理的 bindMS 属性,以确保 m_ActiveCameraDepthAttachment 不解析(resolve)采样纹理,维持其多 Samples 的格式与内容

depthDescriptor.bindMS = msaaSamples > 1 && (SystemInfo.supportsMultisampledTextures != 0);

好了搞定,这下 DepthCopyPass 设置输入的 DepthTexture 就是多 Samples 的 Texture,你就可以在 shader 里进行自定义 resolve,这块 URP 已经帮我们做了

这一部分的内容其实就是上面软件 resolve 的流程,代码也可以直接参考

注意这里有一个坑:DepyCopyPass 帮你 resolve 的深度是多采样求最值,这个算法没问题,但是由于采样点位置不同,因此不能保证它和你后续直接采样纹理中心点的结果相同,它们存在小小的误差,后续如果要在这张 copy 后的 Texture 中继续写入单采样的深度结果,就最好不要用等于(Equal 或者 LessEqual)判断深度

既然这也是一个可行的 MSAA 方案,URP 为什么没有这么去做?

其实原先 URP 有这么做的,只不过后面为了解决部分 OpenGL 设备黑屏的问题,又把对应的功能给阉割了,具体可以参考 CHANGE.LOG 文件和对应 git 提交,也可以参考这篇链接

因此不排除我们实现了 MSAA,原先部分设备黑屏的问题还是会出现,因此如果采取该方案,我们还需要对机型做一个筛选:或仅对部分高配机型做 MSAA 的支持

三、硬件平台与性能

3.1 硬件 RenderTexture.ResolveAA 打点

大部分的 MSAA 解析都是硬件做的,而 Unity3D FrameDebug 可以跟踪解析,是因为在底层打了点,但是这有一个前提:就是当前驱动是否关闭了 MSAA 的自动解析,否则 Unity3D 本身是无法跟踪的

这就导致可能 PC D3D11 平台 MSAA 不会有什么问题,但只要切换 PC + OpenGLES3.0 就会发现跟踪不到 resolveAA 了,但是不用怕其实你的 MSAA 依旧生效,可以直接方法游戏画面以确认,阅读 URP / Unity 源码也可以略知一二,一个确定是否自动解析了多重采样纹理的属性就是 SystemInfo.supportsMultisampleAutoResolve

bool PlatformRequiresExplicitMsaaResolve()
{
    return !SystemInfo.supportsMultisampleAutoResolve &&
           SystemInfo.graphicsDeviceType != GraphicsDeviceType.Metal;
}

但如果关闭了 MSAA 的自动解析,就需要引擎底层去手动触发,良心的 Unity 还是把对应的接口给了我们的:RenderTexture.ResolveAntiAliasedSurface,必然底层也帮我们做了这件事


其次就是 ResolveAA 的时机与次数,当然下面内容讨论的前提是 SystemInfo.supportsMultisampleAutoResolve = false

Unity 底层触发硬件解析的方式比较暴力:即每次切换 renderTarget 都会对切换出去的 renderTarget 强制 resolveAA,这操作在某些情况下其实是冗余的,比如两次 renderTarget 不同,但是作为 shader Resources 的 MSTexture 确是同一张,很显然此时你只需要对该 MSTexture 进行一次 resolveAA 就够了,可事实上会有两次

优化方案必然有,网上已经有一个很好的例子了,可以直接参考 github:大致思路就是一个 targetHandle 直接持有至多两张 Texture,其中一张是 MSTexture 格式的,一张是 resolve 后的,然后对于改写了原先的 Get/SetTemporary 和 Idfentifier 方法:

  • GetTemporaryRT 时直接支持直接用 RenderTexture 创建 renderTarget
  • resolve 专门用一个 PASS 去做,并且只在非透明物体渲染后,深度拷贝前做一次,这也意味着在此之后所有渲染都不带 AA
  • 重写 Identifier,支持只获取 MSTexture,或根据是否 resolve 来直接获取结果

整个过程看上去是增加了一个 renderTexture,但就算 unity 本身关闭 bindMS,内部也会有两个renderTexture Handle,所以概念上是等同的

3.2 Set Memoryless

这节可以算作这一节的扩展:片上 MSAA 只省带宽,其实不省系统内存,即仍然会在系统内存中申请 与 MSTexture + Resolve Texture 同等大小的一块区域,直到下一次图元刷新时删除

但如果你只需要 resolve 的结果,那么这块 MSTexture 的内存就完全没必要申请,在 IOS metal 及 vulkan 平台上,支持你设置 RT 的存储模式为 Memoryless 以进一步减少内存开销,这也是苹果官方极力推荐的优化 MSAA 的手段

注意如果纹理是 Memoryless 的,那么这种纹理就是渲染过程中的临时资源,不能在渲染的开始加载纹理的内容,也不能在渲染的结束时保存其内容

3.3 并非所有机型都可以完美支持 MSAA

尽管可以通过获取硬件 Caps(参考 Unity 源码 GraphicsCaps 或者 QualitySetting)来首当其冲排除掉一些硬件上就不支持 MSAA 的设备,但是仍然不能保证所有查询硬件属性支持 MSAA 的设备,都能够不出错,其中就包括前面说的黑屏问题

举个例子,测试了多个 Iphone 设备,其中发现仅 iPad Air 2 开启 MSAA 会黑屏,尽管这个设备放现在基本属于低端机一列,不会通过高端机判定

因此项目是否开启 MSAA 是需要根据机型硬件指数来确定的,一般只有高端机支持开启 MSAA,理论上后续应该进行更全量的云测,以确保拿到一份覆盖大多市面主流设备/驱动的 MSAA 功能及性能相关的测试报告,以做进一步的筛选和判定

除此之外软件 resolve 深度到底使用哪一套方案(preDepth or DepthCopy)还有待商榷,目前来看不能确定哪个性能更优,设备支持更难说,这块只能说任重而道远

其它引用:

猜你喜欢

转载自blog.csdn.net/Jaihk662/article/details/126752896