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

前面介绍了锯齿的产生原因以及 MSAA 解决方案,这里主要是介绍 MSAA 每一步是在哪个时机,那一块地方做的,简单描述下性能上的问题,并且主要考虑移动平台的 TBRD 架构
一样可以先参阅文章:
直入主题: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 的时机:

通过前面的流程也能知道:因为有硬件支持,多 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 问题时会具体提到
其实很简单,就是一个设置:

然后想办法把它做成游戏时可以动态配置的形式:
- 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;
- }
- }

但这只是开始,如果你是大型项目的话,很有可能会不得不面对三个问题:
参考文章:
对于 Alpha to coverage:如果像草和树等 AlphaTest 的物体本身 Texture 就做过边缘柔滑,再加上也都不是特别细(<1pixel),就没有必要开启 Alpha to coverage
Unity 中可以在 shader 中添加如下标签以开启 AtoC:
AlphaToMask On
对于 HDR resolve:如果不存在爆亮区域需要解决锯齿问题,就也可以不做考虑
无脑开启 MSAA 带来的一个必然结果是,凡是用到 depthTexture 的 Shading,可能无一例外全坏:比如基于深度检测的后处理描边等等
为什么会这种情况,要先从 MSAA resolve 的算法说起,对于颜色而言,resolve 算法必定是对多采样点进行平均:不然就不可能得到抗锯齿的效果

但是归限于硬件的一个因素:同一 RT 下 MSAA 时 Depth Stencil 也必须是 MultiSample 的,并且与 Color 的 Sample 数量相同,这样深度格式就也必须是 NxMSAA 的,尽管深度完全不需要抗锯齿
但是这不是导致问题的关键,关键在于:depthTexture 也采取了和 colorTexture 一样的 resolve 算法也就是平均,从而使得边缘深度信息完全出错(并且这样做还没有任何意义),这也是导致上面问题的主要原因
知道了这点之后,其实想解决就很简单:第一个想到的方法必然是修改 depthTexture 的 resolve 算法:由平均改为取最值,但很可惜,前面说过这块是硬件帮我们处理的,因而我们想要修改 resolve 算法,第一步只能从硬件平台 API 上下手
先吹一波这篇文章,其实已经讲得很好了:从一个小 BUG 看 MSAA depth resolve,大致总结下:像 IOS metal 直接就支持我们使用自定义的 resolve 算法,并且使用起来非常简单,但除此之外特别是主流的 Andriod OpenGLES 3.1/3.0 都不能很好的支持,但也有解,比如使用 framebuffer fetch 扩展等等,但无一例外都需要动到引擎源码,能不能搞定还有另说(特别是 Unity)
这样看来,在不动源码的前提下,我们能考虑的也是最简单的:就是跳过硬件 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
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
事实上,你可以查阅的大多数资料,对这一块的解释都是模棱两可的,并不完全正确
其实按照图形硬件及驱动发展的时间线,大致可以两个时间段:
一个前提是:如果你需要在 shader 里用到 depthTexture,那么就需要先勾选 DepthTexture 配置项:这样 URP 内部才会通过 CopyDepth 的方式获取当前摄像机的 depthTexture
![]()
参考对应的源码:
- 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);
- }
目前的 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 的做法,但显然不是唯一解
当然可以,不过要略微修改下 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 的支持
大部分的 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 方法:
整个过程看上去是增加了一个 renderTexture,但就算 unity 本身关闭 bindMS,内部也会有两个renderTexture Handle,所以概念上是等同的
这节可以算作这一节的扩展:片上 MSAA 只省带宽,其实不省系统内存,即仍然会在系统内存中申请 与 MSTexture + Resolve Texture 同等大小的一块区域,直到下一次图元刷新时删除

但如果你只需要 resolve 的结果,那么这块 MSTexture 的内存就完全没必要申请,在 IOS metal 及 vulkan 平台上,支持你设置 RT 的存储模式为 Memoryless 以进一步减少内存开销,这也是苹果官方极力推荐的优化 MSAA 的手段
注意如果纹理是 Memoryless 的,那么这种纹理就是渲染过程中的临时资源,不能在渲染的开始加载纹理的内容,也不能在渲染的结束时保存其内容
尽管可以通过获取硬件 Caps(参考 Unity 源码 GraphicsCaps 或者 QualitySetting)来首当其冲排除掉一些硬件上就不支持 MSAA 的设备,但是仍然不能保证所有查询硬件属性支持 MSAA 的设备,都能够不出错,其中就包括前面说的黑屏问题
举个例子,测试了多个 Iphone 设备,其中发现仅 iPad Air 2 开启 MSAA 会黑屏,尽管这个设备放现在基本属于低端机一列,不会通过高端机判定

因此项目是否开启 MSAA 是需要根据机型硬件指数来确定的,一般只有高端机支持开启 MSAA,理论上后续应该进行更全量的云测,以确保拿到一份覆盖大多市面主流设备/驱动的 MSAA 功能及性能相关的测试报告,以做进一步的筛选和判定
除此之外软件 resolve 深度到底使用哪一套方案(preDepth or DepthCopy)还有待商榷,目前来看不能确定哪个性能更优,设备支持更难说,这块只能说任重而道远
其它引用: