• 大型项目中 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 是单一采样格式

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

    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

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

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

    1. static URPAssetRuntimeParams assetRuntimeParams;
    2. public UniversalRenderPipeline(UniversalRenderPipelineAsset asset)
    3. {
    4. //修改下 URP 源码……
    5. UniversalRenderPipeline.assetRuntimeParams.Init(asset);
    6. }
    7. static void InitializeStackedCameraData(Camera baseCamera, UniversalAdditionalCameraData baseAdditionalCameraData, ref CameraData cameraData)
    8. {
    9. var assetRuntimeParams = UniversalRenderPipeline.assetRuntimeParams;
    10. if (baseCamera.allowMSAA && assetRuntimeParams.msaaSampleCount > 1)
    11. msaaSamples = (baseCamera.targetTexture != null) ?
    12. baseCamera.targetTexture.antiAliasing : assetRuntimeParams.msaaSampleCount;
    13. }
    14. public bool IsOpenMSAA
    15. {
    16. get { return _isOpenMSAA; }
    17. set
    18. {
    19. _isOpenMSAA = value;
    20. UserDataManager.SetData(IS_OPEN_MSAA, GLOBAL_SETING_GROUP, value);
    21. if (IsOpenMSAA)
    22. UniversalRenderPipeline.assetRuntimeParams.msaaSampleCount = 2;
    23. else
    24. UniversalRenderPipeline.assetRuntimeParams.msaaSampleCount = 1;
    25. }
    26. }

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

    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:这其实很好办,如果理解了前面文章里的内容,就很容易想到其中一个方案:不进行解析,直接将渲染纹理绑定为着色器中的多采样纹理

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

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

    1. #define DEPTH_TEXTURE_MS(name, samples) Texture2DMS name
    2. #if MSAA_SAMPLES == 1
    3. DEPTH_TEXTURE(_CameraDepthAttachment);
    4. SAMPLER(sampler_CameraDepthAttachment);
    5. #else
    6. DEPTH_TEXTURE_MS(_CameraDepthAttachment, MSAA_SAMPLES);
    7. float4 _CameraDepthAttachment_TexelSize;
    8. #endif
    9. float SampleDepth(float2 uv)
    10. {
    11. #if MSAA_SAMPLES == 1
    12. return SAMPLE(uv);
    13. #else
    14. int2 coord = int2(uv * _CameraDepthAttachment_TexelSize.zw);
    15. float outDepth = DEPTH_DEFAULT_VALUE;
    16. UNITY_UNROLL
    17. for (int i = 0; i < MSAA_SAMPLES; ++i)
    18. outDepth = DEPTH_OP(LOAD(coord, i), outDepth);
    19. return outDepth;
    20. #endif
    21. }

    好了到此 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 使用
    1. if (cameraData.renderType == CameraRenderType.Base)
    2. {
    3. m_ActiveCameraColorAttachment = (createColorTexture) ? m_CameraColorAttachment : RenderTargetHandle.CameraTarget;
    4. m_ActiveCameraDepthAttachment = (createDepthTexture) ? m_CameraDepthAttachment : RenderTargetHandle.CameraTarget;
    5. bool intermediateRenderTexture = createColorTexture || createDepthTexture;
    6. // Doesn't create texture for Overlay cameras as they are already overlaying on top of created textures.
    7. bool createTextures = intermediateRenderTexture;
    8. if (createTextures)
    9. CreateCameraRenderTarget(context, ref renderingData.cameraData);
    10. }
    1. if (!requiresDepthPrepass && renderingData.cameraData.requiresDepthTexture && createDepthTexture)
    2. {
    3. m_CopyDepthPass.Setup(m_ActiveCameraDepthAttachment, m_DepthTexture);
    4. EnqueuePass(m_CopyDepthPass);
    5. }

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

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

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

    1. // 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.
    2. bool createDepthTexture = cameraData.requiresDepthTexture && !requiresDepthPrepass;
    3. createDepthTexture |= (cameraData.renderType == CameraRenderType.Base && !cameraData.resolveFinalTarget);
    4. if (requiresDepthPrepass)
    5. {
    6. m_DepthPrepass.Setup(cameraTargetDescriptor, m_DepthTexture);
    7. EnqueuePass(m_DepthPrepass);
    8. }

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

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

    2.2.1 还有别的方案嘛

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

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

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

    但是只改这个的话,进游戏会出错,原因(报错内容)是: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

    1. bool PlatformRequiresExplicitMsaaResolve()
    2. {
    3. return !SystemInfo.supportsMultisampleAutoResolve &&
    4. SystemInfo.graphicsDeviceType != GraphicsDeviceType.Metal;
    5. }

    但如果关闭了 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)还有待商榷,目前来看不能确定哪个性能更优,设备支持更难说,这块只能说任重而道远

    其它引用:

  • 相关阅读:
    AOP+反射 批量参数校验
    012:计算影线长度占比
    ansible的介绍安装与模块
    Spring IoC容器简介说明(BeanFactory和ApplicationContext)
    边界、融合与突破:启明星辰集团郭春梅详解云原生安全技术与策略
    [DP] DP优化总结
    【SAP消息号C0432】
    [附源码]计算机毕业设计springboot健康医疗体检
    数据结构:顺序表
    质量评估模型助力风险决策水平提升
  • 原文地址:https://blog.csdn.net/Jaihk662/article/details/126752896