风格化场景后处理

N 人看过

老早之前玩过破晓传奇来着,当时就觉得场景的风格化做的挺有意思,所以和同事截帧分析了一波。发现他们场景物体贴图上并没有类似水彩笔触一样的纹路,所以理所当然地觉着是后处理做的。

至于具体是怎么做的,在写这篇文的时候正主已经出来进行过技术分享了,笔触的效果主要是双边滤波的SNN Filter,以及轮廓线。核心是加入了多种权重来调整卷积核,例如边缘检测结果,深度等。具体的已有大佬做了分析:

技术分享:https://zhuanlan.zhihu.com/p/568731521

技术分析:http://www.freeexc.me/p/%E7%A0%B4%E6%99%93%E4%BC%A0%E8%AF%B4%E6%8A%80%E6%9C%AF%E7%BE%8E%E6%9C%AF%E5%88%86%E6%9E%90%E4%B8%8E%E9%83%A8%E5%88%86%E6%95%88%E6%9E%9C%E5%AE%9E%E7%8E%B0%EF%BC%88%E5%89%8D%E7%AF%87%EF%BC%89

SNN代码:https://www.shadertoy.com/view/MlyfWd

而之前我实现的方法简陋很多,只能说是大道理差不多。先看看实现的效果:

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;

来源:https://yemi.me/2018/03/11/shader-npr-oilpaint/

实在是暴力,电脑还好,放在手机上跑直接就黑屏了,可能是逐像素存一个20长度的数组对手机来说还为时尚早。

现在用的是算法思路来自这位大佬:

https://www.cnblogs.com/ezhar/p/12836248.html

我的实现加入了一个随深度变化的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) 进行许可。