寫在前面
【技術(shù)美術(shù)圖形部分】簡(jiǎn)述主流及新的抗鋸齒技術(shù),花了點(diǎn)時(shí)間盤點(diǎn)了一些主流AA技術(shù),再在SRP下的URP管線中實(shí)現(xiàn)一下目前游戲用得比較多的TAA。參考Unity的TAA(比較容易懂)以及sienaiwun的實(shí)現(xiàn)思路,也參考了很多文章(主要是這位大佬),可以說(shuō)這次實(shí)現(xiàn)其實(shí)是對(duì)目前能找得到的實(shí)現(xiàn)思路的大融合!
本文敘述會(huì)盡可能多地體現(xiàn)自己的理解,也算是一次學(xué)習(xí)。
前置知識(shí)
SRP?
隨意找了篇介紹SRP的,可以看看:Unity URP/SRP 渲染管線淺入深出【匠】
全稱,可編程渲染管線。
相信很多人跟我一樣,入門階段一直是基于Unity內(nèi)置管線(Build-in)開(kāi)展學(xué)習(xí),直到后面接觸到了HDRP(高清渲管線)和URP(通用管線),才發(fā)現(xiàn)要學(xué)的東西還有好多,,,而且不止這些,甚至還要學(xué)習(xí)和這些光柵化管線完全不同的光追管線,,,自以為“入門了”但其實(shí)人還在坑里呢,現(xiàn)在逃還來(lái)得及!!!(不是)
回到正題!其實(shí)SRP并不可怕,只需要知道有了它我們可以通過(guò)C#腳本調(diào)用封裝的API來(lái)創(chuàng)建自定義管線,渲染流程可自定和修改——就能實(shí)現(xiàn)管線定制了,沒(méi)有Build-in那么死板。URP和HDRP就是兩個(gè)Unity基于SRP為我們拓展的管線模板,URP難就難在一些函數(shù)名稱完全被更改,還需要重寫函數(shù)。
我學(xué)習(xí)SRP的思路一直都是以實(shí)現(xiàn)某種功能為目標(biāo),嘗試在SRP搭建管線(之前做過(guò)URP下實(shí)現(xiàn)毛玻璃),以及這一篇SRP下自定義管線實(shí)現(xiàn)TAA。剛?cè)腴T啥都不會(huì)的跟著官方阿b發(fā)的視頻做一次:URP系列教程 | 如何使用Scriptable Renderer Feature來(lái)自定義后處理效果
說(shuō)得挺亂的,總之一句話:SRP和固定管線絕對(duì)不是完全割裂的,遇到新管線不需要害怕,學(xué)就完事了。
Volume組件
這部分可以看看:如何擴(kuò)展Unity URP的后處理Volume組件
實(shí)現(xiàn)TAA還需要了解URP的Volume組件——URP實(shí)現(xiàn)屏幕后處理的核心組件,我們可以通過(guò)Volume組件下的Add Override添加屏幕后處理效果:
我們?nèi)绻雽?shí)現(xiàn)TAA,也需要添加一個(gè)類似“TAA開(kāi)關(guān)”的東西,意味著我們需要去拓展Volume Overide,簡(jiǎn)單來(lái)講就是再寫一個(gè)TAA,cs的實(shí)現(xiàn)腳本。
TAA實(shí)現(xiàn)思路
強(qiáng)烈建議看完這個(gè)篇文章:DX12渲染管線(2) - 時(shí)間性抗鋸齒(TAA)
RenderFeature實(shí)現(xiàn)TAA
TemporalAA需要處理靜止和動(dòng)態(tài)畫面,而動(dòng)態(tài)需要解決殘影問(wèn)題,參考大部分文章的思路,本文也將通過(guò)RendererFeature去實(shí)現(xiàn)TAA,實(shí)現(xiàn)過(guò)程的話需要以下三個(gè)部分,
- 處理渲染管線
- 處理Global Volume
- Shader實(shí)現(xiàn)TAA混合
整體框架如下:

大概思路:首先,我們需要?jiǎng)?chuàng)建TAARendererFeature.cs和Renderer,
再把RendererFeature給Add一下Renderer,?
然后在這個(gè)RenderFeature里生成相機(jī)抖動(dòng)值,通過(guò)一個(gè)TAARenderPass.cs實(shí)現(xiàn)最終的畫面渲染?。還需要一個(gè)專門的抖動(dòng)相機(jī)的Pass,CameraSettingPass.cs,用于改變相機(jī)的透視變換矩陣(后面會(huì)講到)。此外還需要給Global Volume加上自定義的TAA,那就又要?jiǎng)?chuàng)建一個(gè)TAA.cs文件,作為一個(gè)TAA開(kāi)關(guān)。
于是細(xì)化的話,步驟主體包括以下腳本,
- 一個(gè)RendererFeature類:TAARendererFeature.cs
- 一個(gè)RenderPass類:TAARenderPass.cs
- 一個(gè)RenderPass類:CameraSettingPass.cs
- 與Global Volume搭接:TAA.cs
對(duì)框架做一個(gè)簡(jiǎn)單的概述后,開(kāi)始從實(shí)現(xiàn)靜態(tài)場(chǎng)景的TAA出發(fā),一點(diǎn)一點(diǎn)寫腳本和shader:
1 靜態(tài)場(chǎng)景
靜態(tài)場(chǎng)景理解起來(lái)很容易,需要在下一幀渲染時(shí)將采樣上一幀的子像素點(diǎn)偏移,確保每一幀偏移不同位置,最終取全部幀的平均值,整個(gè)過(guò)程涉及到以下兩點(diǎn),
- 抖動(dòng)采樣——如何進(jìn)行采樣偏移?
- 歷史幀混合——如何混合歷史幀得到平均值?
1.1 抖動(dòng)采樣
采樣方法
在采樣點(diǎn)個(gè)數(shù)上,TAA和傳統(tǒng)超采樣的想法是一致的——生成更多的采樣點(diǎn)以獲得更加細(xì)致的采樣效果,但因?yàn)槭侵饚秳?dòng)再取所有的均值,除了每一幀的空間上采樣還涉及逐幀的時(shí)間上采樣,所以采樣方法也涉及到兩個(gè)方面,
- 空間上——我們希望采樣點(diǎn)不能太過(guò)隨機(jī),不然總會(huì)有堆疊的情況
- 時(shí)間上——我們希望逐幀也能夠均勻分布
最后我選取的采樣方法參考了這篇文章:采用Stratified sampler進(jìn)行空間上的采樣(有時(shí)間的話還可以拿box和泊松給它對(duì)比一下),時(shí)間上選擇了前8個(gè)halton(2,3)進(jìn)行相機(jī)抖動(dòng):


先看看如何生成Halton序列吧,我并沒(méi)有單獨(dú)設(shè)一個(gè)Halton數(shù)列的Class,而是參考大佬思路囊括進(jìn)了RendererFeature中,淺看一下:
// 抖動(dòng)用的Halton
// 這里直接照搬大佬設(shè)置的函數(shù)了,沒(méi)多余時(shí)間細(xì)究了orz
private float HaltonSeq(int prime, int index = 1/* NOT! zero-based */)
{
float r = 0.0f;
float f = 1.0f;
int i = index;
while (i > 0)
{
f /= prime;
r += f * (i % prime);
i = (int)Mathf.Floor(i / (float)prime);
}
return r;
}
上述代碼的生成Halton序列的方法中i指的就是序列的第幾位數(shù),再根據(jù)Halton數(shù)列計(jì)算抖動(dòng)值,實(shí)現(xiàn)是在TAARendererFeature的AddRenderPasses()方法中實(shí)現(xiàn),其中haltonIndex初始值為0:
// 獲取Offset值
if(++haltonIndex >= max_SampleCount)
{
haltonIndex = 0;
}
haltonIndex = (haltonIndex + 1) & 1023;
Vector2 offset = new Vector2(
HaltonSeq(2, haltonIndex + 1) - 0.5f,
HaltonSeq(3, haltonIndex + 1) - 0.5f);
【偏題】采樣在圖形學(xué)中是十分常見(jiàn)的部分,隨機(jī)數(shù)影響著樣本的分布,我參考的文章中提到了這篇文章:低差異序列(一)- 常見(jiàn)序列的定義及性質(zhì),打開(kāi)新世界大門,這里展示出來(lái),也給自己碼一下吧,有機(jī)會(huì)一定拜讀。
Jitter視錐體
我們需要逐幀偏移采樣點(diǎn),這個(gè)offset需要發(fā)生在幾何階段之后的光柵化階段,也就是屏幕映射之后。上述的“相機(jī)抖動(dòng)”并不是偏移相機(jī)的位置,而是視錐中心動(dòng)、基于視錐底部偏移一定的Offset,還原到世界空間就是下圖:

所以說(shuō),我們改動(dòng)的其實(shí)是Project矩陣,搬運(yùn)其他大佬對(duì)偏移如何轉(zhuǎn)化為修改矩陣的解釋:

實(shí)現(xiàn)這個(gè)點(diǎn),我們需要在TAARendererFeature中封裝一個(gè)變換矩陣函數(shù):
// 變換矩陣
private Matrix4x4 GetJitteredProjectionMatrix(Camera camera, Vector2 offset, Vector2 jitterIntensity)
{
Matrix4x4 originalProjMatrix = camera.nonJitteredProjectionMatrix;
float near = camera.nearClipPlane;
float far = camera.farClipPlane;
Vector2 matrixOffset = offset * new Vector2(1f / camera.pixelWidth, 1f / camera.pixelHeight) * jitterIntensity;
//[row, column]
originalProjMatrix[0, 2] = matrixOffset.x;
originalProjMatrix[1, 2] = matrixOffset.y;
return originalProjMatrix;
}
接下來(lái)就是在渲染場(chǎng)景前,通過(guò)CommandBuffer.SetViewProjectionMatrices修改相機(jī)的VP矩陣了,這部分在CameraSettingPass里實(shí)現(xiàn),
// 修改用于渲染的VP矩陣
cmd.SetViewProjectionMatrices(cameraData.camera.worldToCameraMatrix, m_TAAData.jitteredProj);
這里的m_TAAData是額外的一個(gè)用于傳遞參數(shù)的類,既然這里涉及了,那也把TAAData的腳本展示一下:
腳本TAAData.cs
其中jitteredProj就是在TAARendererFeature中定義的,把變換矩陣傳遞給CameraSettingPass
public class TAAData
{
public Vector2 offset;
public Vector2 lastOffset; // 儲(chǔ)存上一幀的Offset
public Matrix4x4 lastProj;
public Matrix4x4 lastView;
public Matrix4x4 jitteredProj;
public Matrix4x4 currentView;
public void Initialize()
{
offset = Vector2.zero;
lastOffset = Vector2.zero;
lastProj = Matrix4x4.identity;
lastView = Matrix4x4.identity;
jitteredProj = Matrix4x4.identity;
currentView = Matrix4x4.identity;
}
}
再把計(jì)算得到的Offset值、變換矩陣、傳遞給TAAData就行了,這個(gè)部分還需要結(jié)合接下來(lái)要說(shuō)1.2 混合歷史幀。
腳本CameraSettingPass.cs
至此可以展示一下整個(gè)CameraSettingPass的樣子了:
public class CameraSettingPass : ScriptableRenderPass
{
// Profiling上顯示
ProfilingSampler m_ProfilingSampler;
string m_ProfilerTag = "CameraSetting";
TAAData m_TAAData;
private TAARendererFeature.TAAData taaData;
internal CameraSettingPass()
{
renderPassEvent = RenderPassEvent.BeforeRenderingOpaques;
}
// 非RenderPass的重寫函數(shù),是我們自定義的,用以傳遞參數(shù)
// 這里傳遞的是TAAData
internal void Setup(TAAData data)
{
m_TAAData = data;
}
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
CommandBuffer cmd = CommandBufferPool.Get(m_ProfilerTag);
using (new ProfilingScope(cmd, m_ProfilingSampler))
{
context.ExecuteCommandBuffer(cmd);
cmd.Clear();
CameraData cameraData = renderingData.cameraData;
// 修改用于渲染的VP矩陣
cmd.SetViewProjectionMatrices(cameraData.camera.worldToCameraMatrix, taaData.jitteredProj);
}
// 執(zhí)行
context.ExecuteCommandBuffer(cmd);
// 回收
CommandBufferPool.Release(cmd);
}
}
腳本TAARendererFeature.cs
1.2 歷史幀混合
混合方式
已經(jīng)實(shí)現(xiàn)了相機(jī)抖動(dòng),最終我們還需要將不同采樣點(diǎn)渲染的畫面混合,也就是混合N個(gè)歷史幀。
那么采取什么樣的混合方式呢?
最最簡(jiǎn)單的方法就是直接取均值,(第8幀+前7幀結(jié)果)/8,這樣開(kāi)銷很大。換一種思路:取當(dāng)前幀的結(jié)果與前一幀的結(jié)果按一定比例混合,有遞歸那味兒了!
那么如何實(shí)現(xiàn)?按什么比例?為什么能這樣按比例混合?還是搬運(yùn)其他大佬的解釋:

這里混合系數(shù)對(duì)效果也是有影響的。
RT儲(chǔ)存歷史幀
為了儲(chǔ)存歷史幀,需要一張能長(zhǎng)期使用的RT——Motion Vector RT,Motion Vector是一個(gè)RGHalf(或RGFloat)的雙通道貼圖,儲(chǔ)存的是當(dāng)前像素點(diǎn)與上一幀的區(qū)別。通過(guò)這個(gè)RT找到上一幀的當(dāng)前像素信息,混合。
2 動(dòng)態(tài)場(chǎng)景
靜態(tài)場(chǎng)景直接supersampling就可以解決了,那動(dòng)態(tài)場(chǎng)景涉及到兩個(gè)方面,
- 攝像機(jī)移動(dòng)
- 場(chǎng)景中物體移動(dòng)
一旦畫面有移動(dòng),場(chǎng)景中某點(diǎn)位置會(huì)發(fā)生變化。如果繼續(xù)用原屏幕位置混合,會(huì)出現(xiàn)問(wèn)題,這個(gè)時(shí)候怎么實(shí)現(xiàn)抗鋸齒?
這牽扯到很多技術(shù),我們一步一步來(lái)。
2.1 Reprojection
考慮鏡頭移動(dòng):
這里就要用到Reprojection方法:渲染當(dāng)前幀(N位置)時(shí)需要乘以當(dāng)前幀的VP矩陣的逆,再乘以上一幀(N-1位置)的VP矩陣,齊次除法變換到上一幀的裁剪空間,就知道當(dāng)前幀的N位置(UV)在上一幀畫面中的位置(lastUV)了。這一步體現(xiàn)在我們最后的TAAShader的fragment shader里:
// Reprojeciton
float4 worldPos = mul(UNITY_MATRIX_I_VP, positionNDC);
worldPos /= worldPos.w;
float4 lastPositionCS = mul(_LastViewProj, worldPos);
float2 lastUV = lastPositionCS.xy / lastPositionCS.w;
lastUV = lastUV * 0.5 + 0.5;
消除Jitter影響
還需要還原抖動(dòng),不然畫面會(huì)模糊,這時(shí)TAAData里儲(chǔ)存的lastOffset就派上用場(chǎng)啦!如下(直接復(fù)制的格式亂套了orz):
float2 sampleUV = input.texcoord;
float2 currentOffset = _TAAOffsets.xy; // 上一幀的Offset
float2 lastOffset = _TAAOffsets.zw; // 當(dāng)前幀Offset
float2 unJitteredUV = sampleUV - 0.5 * currentOffset; // 還原Offset
...
// 采樣當(dāng)前深度貼圖
float depthTexture = _CameraDepthTexture.SampleLevel(sampler_PointClamp, unJitteredUV, 0).r;
float4 positionNDC = float4(sampleUV * 2 - 1, depthTexture, 1);
#if UNITY_UV_STARTS_AT_TOP
positionNDC.y = -positionNDC.y;
#endif
// Reprojeciton
float4 worldPos = mul(UNITY_MATRIX_I_VP, positionNDC);
worldPos /= worldPos.w;
float4 lastPositionCS = mul(_LastViewProj, worldPos);
float2 lastUV = lastPositionCS.xy / lastPositionCS.w;
lastUV = lastUV * 0.5 + 0.5;
// 用當(dāng)前幀在上一幀(累積幀)的位置采樣累積幀畫面
float3 accumTexture = _AccumTexture.SampleLevel(sampler_LinearClamp, lastUV, 0).rgb;
但是這有個(gè)問(wèn)題,,只是簡(jiǎn)單的使用上一幀的VP矩陣進(jìn)行reprojection,僅適用于靜態(tài)場(chǎng)景下的動(dòng)態(tài)攝像機(jī)。對(duì)于動(dòng)態(tài)場(chǎng)景下的動(dòng)態(tài)攝像機(jī)該怎么辦?
2.2 Neighborhood Clipping
鏡頭移動(dòng)還要考慮一種情況:遮擋問(wèn)題,如果不考慮遮擋,會(huì)出現(xiàn)殘影:
下圖解釋了這個(gè)現(xiàn)象出現(xiàn)的原因:?
由于我們直接混合了歷史幀,導(dǎo)致上一幀被遮擋的東西這一幀突然出現(xiàn),或者這一幀本來(lái)應(yīng)該有的東西被遮擋。
解決這一問(wèn)題,基于鄰近像素色彩的 Neighborhood Clamping 是目前比較主流的 TAA 歷史幀約束方案:就是限制歷史采樣的顏色范圍,把歷史幀采樣結(jié)果clamp到一個(gè)范圍(AABB給它包圍起來(lái))。
進(jìn)一步優(yōu)化:NIVIDA又提出了更好的方法,Variance clipping,縮小了AABB的尺寸。具體方法詳見(jiàn)DX12渲染管線(2) - 時(shí)間性抗鋸齒(TAA)和在 Unity SRP 實(shí)現(xiàn) Temporal Anti-aliasing,理論方面不太想贅述了,,直接上代碼:
float3 clip_aabb(float3 aabb_min, float3 aabb_max, float3 avg, float3 input_texel)
{
// clip to center:
float3 p_clip = 0.5 * (aabb_max + aabb_min);
float3 e_clip = 0.5 * (aabb_max - aabb_min) + FLT_EPS;
float3 v_clip = input_texel - p_clip;
float3 v_unit = v_clip / e_clip;
float3 a_unit = abs(v_unit);
float ma_unit = max(a_unit.x, max(a_unit.y, a_unit.z));
if (ma_unit > 1.0){
return p_clip + v_clip / ma_unit;
} else{
return input_texel; // 在AABB里
}
}
Variance clipping:
參考了DX12渲染管線(2) - 時(shí)間性抗鋸齒(TAA)的思路:
// Variance clip
float3 m1 = 0, m2 = 0;
for(int k=0; k<9; k++) {
float3 c = _MainTex.Sample(sampler_PointClamp, unJitteredUV, kOffsets3x3[k]);
m1 += c;
m2 += c * c;
}
float3 mu = m1 / 9;
// 估算的 sigma
// 關(guān)于正確的sigma可以參考:https://gist.github.com/BlurryLight/145131dbacac34345908c529a3488e8f
float3 sigma = sqrt(abs(m2 / 9 - mu * mu));
#define VarianceClipGamma 1.0F
float3 minc = mu - VarianceClipGamma * sigma;
float3 maxc = mu + VarianceClipGamma * sigma;
prevColor = ClipAABB(minc, maxc, prevColor, mu);
其中,
float du = _TextureSize.z;
float dv = _TextureSize.w;
float2 kOffsets3x3[9] =
{
float2(-du, -dv),
float2(0, -dv),
float2(du, -dv),
float2(-du, 0),
float2(0, 0),
float2(du, 0),
float2(-du, dv),
float2(0, dv),
float2(du, dv)
}
2.3 Fluckering 高光閃爍問(wèn)題
鏡頭不動(dòng)的時(shí)候,會(huì)有高光(高頻著色區(qū)域)閃爍問(wèn)題。
這個(gè)問(wèn)題其實(shí)我也只是復(fù)述了各大文章里提到的,其實(shí)自己并沒(méi)有真的看到過(guò),計(jì)劃給他實(shí)現(xiàn)之后看看能不能貼個(gè)閃爍的圖出來(lái)吧。
參考
大佬文章
在Unity SRP中實(shí)現(xiàn)TAA效果 | ZZNEWCLEAR13
在 Unity SRP 實(shí)現(xiàn) Temporal Anti-aliasing - 知乎 (zhihu.com)
Unity Temporal AA的改進(jìn)與提高 - 知乎 (zhihu.com)
Unity TAA實(shí)現(xiàn)雜記 | Blurred code
Raphael2048/AntiAliasing (github.com)
DX12渲染管線(2) - 時(shí)間性抗鋸齒(TAA) - 知乎 (zhihu.com)
處理方案
EPIC:TAA
在SIGGRAPH2014上分享了UE4的TAA抗鋸齒技術(shù):
High Quality Temporal Supersampling
NIVIDA:TXAA
GDC2016上分享了TXAA,是優(yōu)化版的TAA吧,解決了一些TAA
Slide 1 (nvidia.cn)
PLAYDEAD
GDC Vault - Temporal Reprojection Anti-Aliasing in INSIDE文章來(lái)源:http://www.zghlxwxcb.cn/news/detail-497721.html
PlayDead提供了TAA源碼文章來(lái)源地址http://www.zghlxwxcb.cn/news/detail-497721.html
到了這里,關(guān)于【Unity SRP】實(shí)現(xiàn)基礎(chǔ)的Temporal AA(未完)的文章就介紹完了。如果您還想了解更多內(nèi)容,請(qǐng)?jiān)谟疑辖撬阉鱐OY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!