风格化场景后处理
老早之前玩过破晓传奇来着,当时就觉得场景的风格化做的挺有意思,所以和同事截帧分析了一波。发现他们场景物体贴图上并没有类似水彩笔触一样的纹路,所以理所当然地觉着是后处理做的。
至于具体是怎么做的,在写这篇文的时候正主已经出来进行过技术分享了,笔触的效果主要是双边滤波的SNN Filter,以及轮廓线。核心是加入了多种权重来调整卷积核,例如边缘检测结果,深度等。具体的已有大佬做了分析:
而之前我实现的方法简陋很多,只能说是大道理差不多。先看看实现的效果:
B站外链视频太糊了,麻烦点击本站观看~
效果开关对比:



画面逆向
这里先放两张游戏场景的截图:


可以看到笔触效果是非常明显的,并且物体的轮廓很干净;当时我们放大看了下,生成的笔触色块周围也是存在类似描边的效果;同时,对于色块的分布程度是会随着相机距离进行变化的,也就说卷积核的大小与深度存在函数关系。
总结下来,我最后实现的是三个主要部分,彩块化,对全屏法线的描边,对彩块化结果进行描边。(当时比较菜,还不知道双边滤波这种东西,以为干净的轮廓就是描边的产物,尴尬。)
具体实现
这里是在URP延迟底下RenderFeature实现的,申请了一张额外rt,然后和相机rt互相倒着渲来做的。彩块化部分还有一个可开关的double render操作,即用相同的采样数和step再渲一次,开销比起无脑提高卷积核采样数要稍微小一点。代码结构是用宏来开关同一个片元里头的代码,虽然变体增加了,但是计算的复用程度也变高了,大概也是省了吧。
彩块化
当时我并不知道SNN Filter这个牛逼名字,我只知道ps有个彩块化的滤镜,和我要的效果很像,于是去找这玩意的算法。一开始找着的是个暴力算法:
int a[20] = { 0,0,0,0,0, 0,0,0,0,0, 0,0,0,0,0, 0,0,0,0,0 };
half4 c[20] = {
half4(0,0,0,0),half4(0,0,0,0),half4(0,0,0,0),half4(0,0,0,0),half4(0,0,0,0),
half4(0,0,0,0),half4(0,0,0,0),half4(0,0,0,0),half4(0,0,0,0),half4(0,0,0,0),
half4(0,0,0,0),half4(0,0,0,0),half4(0,0,0,0),half4(0,0,0,0),half4(0,0,0,0),
half4(0,0,0,0),half4(0,0,0,0),half4(0,0,0,0),half4(0,0,0,0),half4(0,0,0,0),
};
for (int x = -5; x <= 5; x++)
{
for (int y = -5; y <= 5; y++)
{
half4 p1 = tex2D(_MainTex, i.uv + 0.001 * half2(x,y));
int pi1 = int((p1.r + p1.g + p1.b) / 3.0 * 19);
a[pi1] ++;
c[pi1] += p1;
}
}
int index = 0;
int max = a[0];
for (int n = 0; n < 20; n++)
{
if (a[n] >= max)
{
max = a[n];
index = n;
}
}
half4 oc = c[index] / max;
oc.a = 1;
实在是暴力,电脑还好,放在手机上跑直接就黑屏了,可能是逐像素存一个20长度的数组对手机来说还为时尚早。
现在用的是算法思路来自这位大佬:
我的实现加入了一个随深度变化的step值:
float curDep = LinearEyeDepth(SampleSceneDepth(i.uv), _ZBufferParams);
float n = float((_LoopTime + 1) * (_LoopTime + 1));
float3 m0 = 0.0;
float3 m1 = 0.0;
float3 s0 = 0.0;
float3 s1 = 0.0;
float3 c;
float curStep = _DepthMulti == 0 ? _Step : lerp(1, _DepthPow, saturate((_DepthMulti - curDep) / _DepthMulti)) * _Step;
for (int x = -_LoopTime; x <= 0; ++x)
{
for (int y = -_LoopTime; y <= 0; ++y)
{
c = SAMPLE_TEXTURE2D_X(_ScreenTex, sampler_ScreenTex, i.uv + float2(y, x) * curStep).rgb;
m0 += c;
s0 += c * c;
}
}
for (int v = 0; v <= _LoopTime; ++v)
{
for (int u = 0; u <= _LoopTime; ++u)
{
c = SAMPLE_TEXTURE2D_X(_ScreenTex, sampler_ScreenTex, i.uv + float2(u, v) * curStep).rgb;
m1 += c;
s1 += c * c;
}
}
float4 finalFragColor = 0;
float min_sigma2 = 1e+2;
m0 /= n;
s0 = abs(s0 / n - m0 * m0);
float sigma2 = s0.r + s0.g + s0.b;
if (sigma2 < min_sigma2)
{
min_sigma2 = sigma2;
finalFragColor = float4(m0, 1.0);
}
m1 /= n;
s1 = abs(s1 / n - m1 * m1);
sigma2 = s1.r + s1.g + s1.b;
if (sigma2 < min_sigma2)
{
finalFragColor = float4(m1, 1.0);
}
oc = finalFragColor;
当时用着的时候其实是不太懂的,只知道这个效果还行,采样也只有两个象限,直接把采样数干到了一半。现在时代变了,知道这玩意是为啥能这么算了。
主要是用了通过均值计算方差的公式:Var(X) = E(X2) - E2(X),分别得到着色点两个象限的rgb值方差和然后比较,比较小的区域内颜色的统一性就越高。至于min_sigma2这个阈值为什么设置为0.01,我其实是不太懂的,或许是方差分析P值的0.01?或者是近似了一个高斯分布?
查了挺久,只能归结于这个0.01或许是一个统计学上的经验值。不过按我的理解,何必搞这个0.01呢,直接俩方差做一个比较,谁小就用谁:
m0 /= n;
s0 = abs(s0 / n - m0 * m0);
m1 /= n;
s1 = abs(s1 / n - m1 * m1);
float sigma2 = s0.r + s0.g + s0.b;
float sigma3 = s1.r + s1.g + s1.b;
oc = sigma2 < sigma3 ? float4(m0, 1.0) : float4(m1, 1.0);
我目前看不出这两种写法有什么差别,结果肉眼看不出区别。代码少了好几行呢,省点是点。
描边
两次描边用的都是同一个函数,采样了周围四个像素值,没记错的话是shader入门精要的经典传承:
float CheckSame(float3 center, float cenDep, float3 sample, float samDep, float2 sensitivity)
{
float2 centerNormal = center.xy;
float centerDepth = cenDep;
float2 sampleNormal = sample.xy;
float sampleDepth = samDep;
// difference in normals
// do not bother decoding normals - there's no need here
float2 diffNormal = abs(centerNormal - sampleNormal) * sensitivity.x;
int isSameNormal = ((diffNormal.x + diffNormal.y) < 0.1) ? 1 : 0;
// difference in depth
float diffDepth = abs(centerDepth - sampleDepth) * sensitivity.y;
// scale the required threshold by the distance
int isSameDepth = (diffDepth < (0.1 * centerDepth)) ? 1 : 0;
// return:
// 1 - if normals and depth are similar enough
// 0 - otherwise
return isSameNormal * isSameDepth ? 1.0 : 0.0;
}
这部分的效果还是挺好看的:


由于现在用的是采样完法线图的结果进行描边,高频的细节法线描边结果就让整个画面有点脏了。补救的做法是简单加了一个NDL的判断对描边结果进行遮罩,让向光面的描边颜色更接近像素本来颜色,其实也是治标不治本。
严格来说,这里的法线描边应该输入全屏模型法线图,但是代价就是要多两个通道放模型法线,是否值得呢,不好说。
另外,还有些东西是不该进这个彩块化的,像一些本来就很细的东西,电线,栏杆之类的,很大概率会被算法给算出不稳定的颜色,像展示视频中的电线就能看到明显的瑕疵。所以这个Feature实际使用的时候是需要一个逐材质的标记的,属于一个后续要进行的工作。
性能上,视频的效果中,彩块化进行了10+10次的额外采样(其实改巴改巴跟破晓一样用7+7或许效果也是差不多),两次描边分别进行了9次采样。目前测试过在P40Pro是能跑40帧左右的,并且描边对于性能影响非常少,瓶颈在彩块化上。本来抱着逆向的名头去搞的,结果搞出个充满童真的涂鸦效果,只能说是图一乐了……以后如果有时间,再实现一下人家正统的后处理对比效果看看,歇了。
本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 (CC BY-NC-ND 4.0) 进行许可。