Notifications
Article
Temporal AA Anti-Flicker
Published 10 days ago
311
6
这篇文章的目标在于消除Temporal AA(时间采样抗锯齿)的闪烁问题。
其实这是一篇老文章,但是最近看到Unity HDRP的Temporal AA好像和我当时写这篇文章时并没有什么明显的进步,所以还是决定发一下,有需要的朋友可以自取,代码来自魔改的Post Processing V2源码,接入和复现应该比较容易,当然需要配合MPipeline代码一起执行,这里把C#代码和Shader代码一起放上:
https://github.com/MaxwellGengYF/Unity-MPipeline/tree/master/Assets/MPipeline/Scripts/PipelineCore/Events/PostEvent
https://github.com/MaxwellGengYF/Unity-MPipeline/blob/master/Assets/MPipeline/PostProcessing/Shaders/Builtins/TemporalAntialiasing.shader
首先感谢群友@膜力鸭苏蛙可 的文章:https://zhuanlan.zhihu.com/p/64993622 ,这篇文章中使用的Tonemapping给了我很大的启发。
在这篇文章中作者已经介绍了什么是Shading Alias以及常用的抵抗Shading Alias的方法,所谓Shading Alias在文章中介绍的也比较清楚了,反射,高光,自发光在离散的光栅化数据中出现不连续的闪烁点,通过一些“智能”的算法如Tonemapping, Variance Clamp,RGBToYcocg等可以一定程度的克制这种动态的闪烁点。然而在这篇文章中没有介绍到另一种闪烁来源:Geometry Alias。那么这种闪烁是如何出现又如何被消灭掉的呢?
当一个在屏幕空间极其细小的三角形经过光栅化时,谁都不能在看到显示结果时得知其是否被光栅化到了某一个像素上,这就是“薛定谔的光栅化”。
这并不是一句玩笑话也不是危言耸听,原因其实是:Temporal AA阶段进行的Jitter处理实际上是随机或伪随机的,所以这时投影矩阵的偏移量实际上是不能确定的,这就意味着我们并没有办法去预测某个像素点是否被光栅化,那么这种情况表现出来的问题也很明显了,在游戏中当远处有一些细密的三角形分布,如树叶,网格栅栏,百叶窗等,就会产生难以抑制的闪烁,这种闪烁的出现纯粹是因为“某个像素点上的内容不确定”而非单纯的“色彩不确定”,所以通过上方文章的方法实际能解决的程度非常有限,因此这里我们需要使用另外的办法来进行压制。
判断“像素是否还是上一帧的像素”其实方法十分简单,那就是使用深度图,如果上一帧深度与当前帧深度差距大,那就可以顺理成章的判断出上一帧与当前帧在这个像素点上产生了Flicker,这时候直接强行取消Clamp,使两像素融合到一起,也就不会因为Clamp而产生闪烁了。但是投影矩阵的偏移是随机的,那么有可能前两帧或者多帧这个像素点都没有出现,而一次出现了两帧或多帧,这样还是会产生闪烁。解决方法也很简单,利用上一帧储存的渲染结果RT的Alpha通道,将判断信息储存进去,这样这个像素的“黑历史”就会一直被记录和累计,并且在下一帧和普通颜色一样经过按权重比例的混合。
采样上一帧深度和MV在shader层面几乎没什么好讲的,但是在管线层面还是大有文章,因为管线必须允许开发者随意增减每个摄像机储存的信息,并且这些信息需要被长期保留以供给多帧运算需要,因此我们在MPipeline Framework中引入了IPerCameraData这个概念。在MPipeline中首先每一个被传入UnityEngine.Rendering.RenderPipeline.Render(Camera[])方法的摄像机,都会被自动绑定一个PipelineCamera.cs的脚本,这个脚本将会被用来储存渲染需要的Per Camera Data:
然而众所周知Unity提供的GetComponent方法效率并不高,需要遍历并比较GameObject下所有的Component并比较typeid,但是所有的Component都有一个NativeMemoryHook,也就是保证GC不会移动C#实例的内存地址,让C#层和C++层愉快互动的东西,这就意味着我们可以非常愉快的直接将System.object转换成普通的指针,成为普通的非托管类型进行储存,封装如下:
其中PtrKeeper是一个普通的包含一个object的struct,使用Unity.Collection.LowLevel.Unsafe.UnsafeUtility.AddressOf方法,可以获取到任意一个struct的物理地址,并转换成普通指针返回,而需要转换回来的时候只需要逆转这个步骤即可。
这样,在渲染开始前就执行检测步骤,去字典中查询当前摄像机的InstanceID有无对应的方法,如果没有再尝试进行添加步骤:
有了载体之后,就可以在实例中存储摄像机需要的数据,这里封装的IPerCameraData抽象类:
这一套系统核心思想在于是被动更新的生命周期,因为SRP使用与逻辑脚本完全不同的生命周期,所以毫无疑问统一周期让脚本依附是唯一正确的做法,同时,为了防止开发中意外的操作导致内存垃圾,我们放弃C#的Delegate并锁定一个强制为值类型的Interface,这样runnable在传入的时候一定是一个结构体,也就是一个值而非引用,虽然编写上略微有点麻烦,但是从性能角度考虑是值得的。
最后在每帧中开一个或多个可以存储上一帧数据的类即可:
虽然进行边缘检测这种初学者也可以完成的工作非常简单,但是做到这一步时Ghosting问题又卷土重来了,因为不停累计判断的结果,可以说镜头随便一次移动整个屏幕就会充满了Ghosting,Variance Clamp就已经完全被抛弃掉了,这是我们不希望发生的,因此我们将会通过判断Motion Vector Map将正在移动的像素点和曾经移动的像素点的“黑历史”全部放弃,但是因为“黑历史”本身也是累计的,所以单纯判断当前帧的MV是不可行的,需要使用链式思维,用当前帧的Motion Vector获取上一帧的像素UV位置,并用上一帧的像素UV位置读取上一帧的Motion Vector得到上上帧的移动结果,如果两帧之内任何一帧当前像素在移动,那么就直接将黑历史抹去,同时由于光栅化可能产生一个像素的偏差,因此我们不仅要判断上一帧像素的位置,还要判断周围一圈总共9个采样点的位置,整个步骤的代码如下,讲过的每一步都已经写在注释里:
测试的场景我们选择了一个既有HDR自发光又有栅格结构的面片来测试,在自发光足够强的情况下Variance Clamp的作用将会被削弱,Geometry Alias的问题将会被非常明显的暴露出来:
可以看到在上图中,远处的线条已经产生非常明显的断层,这些断层在随机偏移度高的情况下闪烁将会非常严重,使游戏画面遭到巨大损伤,而加上上方算法后的结果就大不一样了,几乎达到了数倍超级采样的效果:
题图就是使用了这样的方法,提高模型边缘较为尖锐的室内场景的渲染效果:

这篇文章的实现到此基本圆满,通过这样的办法压制静态镜头的闪烁,达到基本接近于4xSSAA的效果,在一些场景中将大幅度提高玩家的游戏体验,如FPS游戏中狙击玩家在蹲伏的时候,远处建筑,树林产生的微弱闪烁都会破坏判断力和游戏体验,通过本文的处理方法就能解决问题。
MaxwellGeng
Technical Artist - Programmer
3
Comments
v
vihe
2 hours ago
厉害,刚好有需,谢谢!
0
MaxwellGeng
6 days ago
Technical Artist
bottomer麦老师好!
bo老师好!
0
b
bottomer
7 days ago
麦老师好!
0
Kevin Gu
Staff
9 days ago
UIWidgets Developer
好文
0
汪汪
10 days ago
美工,程序员
领教了,很受用。
0