test 发表于 2020-11-23 19:07

Unity后处理实现The World时停效果

因为是用的后处理方法实现的,和图形就没什么太大关系了,更多的是图像处理。先上效果:
猜猜卡比吞了啥?
参考了下面三张gif,对比一下原版,还是逊色了些,可能是因为动画的场景在运动?我的扭曲效果也差了不少。
第五部中没有了扭曲效果,换成了轻微的抖动。
分析了一下,需要的效果有
反色HSV空间下的hue变化圆形边界的扭曲与运动两个类似rim light的冲击波各向异性的斑纹(最后一张gif比较明显)时停范围内的扭曲时停结束后转为灰度图径向模糊
看上去很多,但每一个都不复杂,下面我们一一展开,Step by Step~
反色

顾名思义,颜色取反。Unity默认的ImageEffectShader就是这个效果。
fixed4 frag(v2f i) : SV_Target
{
    fixed4 col = tex2D(_MainTex, i.uv);
    return 1 - col;
}HSV空间下的hue变化

仔细观察,发现这个特效不单单是取了反,还有一个色相的变化。
可以看到在4帧内,背景色从红色(0°)变成了紫色(-20°)
为了达成这个效果,我们首先要把颜色转到HSV空间,对H分量,也就是色阶进行一些时间相关的变化,然后再把颜色转回RGB空间。
我在网上找到了一个比较trick的算法,没有任何的条件判断语句,十分的GPU友好
float3 RGB2HSV(float3 c)
{
    float4 K = float4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
    float4 p = lerp(float4(c.bg, K.wz), float4(c.gb, K.xy), step(c.b, c.g));
    float4 q = lerp(float4(p.xyw, c.r), float4(c.r, p.yzx), step(p.x, c.r));
    float d = q.x - min(q.w, q.y);
    float e = 1.0e-10;
    return float3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}

float3 HSV2RGB(float3 c)
{
    float4 K = float4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
    float3 p = abs(frac(c.xxx + K.xyz) * 6.0 - K.www);
    return c.z * lerp(K.xxx, saturate(p - K.xxx), c.y);
}实现原理参照这个博客,具体我没有细看(我超虚的)。。
注意,这个算法得到的HSV分量是位于0~1之间的。H分量并不是传统的0~360的范围。
我们让H分量在原色阶右边1/5色环的范围内按时间的正弦函数周期变化。
float3 hsvColor = RGB2HSV(col.rgb);
hsvColor.x += lerp(0,0.2,sin( UNITY_TWO_PI * frac(_Time.y *0.5)));
hsvColor.x = frac(hsvColor.x);
reversedColor.rgb = 1 - HSV2RGB(hsvColor.rgb);
return reversedColor;圆形边界的扭曲与运动

The World的效果类似一个半径不断扩大的球体,球体内部发生反色,而球体外部不变。放在后处理中就是一个圆。另外我们想要的是一个正圆,所以我们求出屏幕长宽比后进行修正。
float aspect = _ScreenParams.x / _ScreenParams.y;
float x = (i.uv.x - 0.5f - _CenterX) * aspect;//would be ellipse if not multiply the aspect
float y = i.uv.y - 0.5f - _CenterY;
float d = x * x + y * y;
...
if ( d < _Radius * _Radius ){
    return reversedColor;但是这个圆它有些"太圆了",我们想要的是一个坑坑洼洼的圆。我的思路是,根据圆心角角度的不同,在原有半径上再叠加一个函数,并保证该函数的连续。首先我们拿正弦函数试试。
float sin_theta = saturate(y / sqrt(d));//d = r^2小心NAN!!!
float half_theta = asin(sin_theta) *(step(0,x)-0.5);
float deformFactor = (1 + 0.02 * sin(half_theta * 24) * lerp(0, 0.5, sin(UNITY_TWO_PI * _Time.y * 0.5)));
_Radius *= deformFactor;在求圆心角的时候, 要注意arcsin函数只够管一四象限的点。对于x小于零的点,我们要做一些计算,保证能得到正确的角度。
嗯,现在边缘不再那么圆了,但是还是很规整,于是自然想到多个正弦波叠加,能够保证它的周期连续性。我这里用了三个,数值是自己乱填的。
float deformFactor = (1 + 0.1 * sin(half_theta * 24) * lerp(0, 0.5, sin(UNITY_TWO_PI * _Time.y * 0.5))
                  + 0.25 * x * sin(1 + half_theta * 6.5) * lerp(0.25, 0.75, sin(UNITY_TWO_PI * _Time.y * 0.2))
                  + 0.1 * x * x * sin(2 + half_theta * 9.5) * lerp(0.25, 0.75, sin(UNITY_TWO_PI * _Time.y * 0.1))
                  );是不是有那么点意思啦!
两个类似rim light的冲击波

回想一下在三维空间中我们是怎么给物体加上泛光描边的:计算视线与法相的夹角,判断该像素是否在边缘,然后根据夹角大小叠加颜色。
而回到我们的二维情况,事情就变的简单多了:指定一个半径,然后进行颜色插值。半径上的光效最强,圆心的颜色为零。
half power = 1.5;
if (d < _ImpactRadius * _ImpactRadius) {
    float t = saturate(d / (_ImpactRadius * _ImpactRadius));//小心NAN!!!
    fixed4 rim = lerp(0, _ImpactColor, pow(t, power));
    finalColor += rim * rim.a;
}power决定了到底按照离圆心距离的几次方进行插值,表现在图像上就是光环的粗细或者说强度。
power=1.5
power=15
各向异性的斑纹

我们从第五部中的时停中截出一帧,可以看到以白金之星的手指为中点发散出去的扭曲的斑纹。
食堂泼辣酱——咋瓦鲁多!
于是我们首先想到的就是……光盘!
批发光碟啦
额不好意思,放错图了。
其实各项异性指的是从不同的角度去看光碟,得到的颜色不同,而并不是指的光碟上不同圆心角的颜色不同,我这里就借这个词用一下,可别被我误导了哈!
为了实现这种效果,我们需要一个函数或者一张噪声图,然后将不同的圆心角映射到不同的颜色值上面去。这里我选择使用贴图。
这张贴图有一个妙的地方,之后会提到
其实我们只有一个变量,就是角度,那对于图片来说,我们也只需要一个维度就行了,也就是从图片中取一条线来用。
fixed4 wave = tex2D(_NoiseTex, half_theta*2) * waveIntensity;
return wave;我们直接输出,看看效果:
嗯,是我们想要的效果。现在我们还需要加上扭曲的斑纹,以及让它随着时间动起来。
思路是扭曲,从刚刚的噪声图中取值,并让该值随着时间规律变化,然后适用这个值对这个大光碟中的点的uv进行偏移。
float4 noise = tex2D(_NoiseTex, i.uv + _Time.xy * twistSpeed);
fixed4 wave = tex2D(_NoiseTex, half_theta*2 + noise.xy * waveShape) * waveIntensity;最后我们把wave的值加到内圈的颜色上去就行了。


时停范围内的扭曲

我们已经简单的叠加上了斑纹,然而内部并没有正真的扭曲,所以我们再加上一个扭曲的效果。而扭曲其实我们刚才已经实现过一遍了(uv偏移),将扭曲斑纹的方法故技重施。
fixed4 twistedColor = tex2D(_MainTex, i.uv + noise.xy * twistIntensity);
...
-float3 hsvColor = RGB2HSV(col.rgb);
+float3 hsvColor = RGB2HSV(twistedColor.rgb);大功告成!
现在刚刚那张噪声图的优越性就体现出来了,因为它是一张边界连续的噪声图,如果不是的话,你会看到很明显的十字扫描线。这里就不贴这种情况的效果图啦。
时停结束后转为灰度图

转化为灰度图也是很方便的事情,使用一个著名的经验公式:

https://www.zhihu.com/equation?tex=gray+%3D+0.299%2Ar%2B0.587%2Ag%2B0.114%2Ab
我们使用一个float变量控制灰度的开关,在发波时为0,收缩时为1。然后我们可以把上述的式子简写成dot的形式。
if(d < _Radius * _Radius){
    ...
}
else{
    if(isGray){
      fixed3 grayFactor = { 0.299,0.587,0.114 };
      fixed grayColor = dot(grayFactor, col);
      ...
    }
}径向模糊

这是一种沿方向的模糊,很容易营造出速度感。
这个的实现我参照的这篇Blog https://blog.csdn.net/xoyojank/article/details/5146297
因为径向模糊是一个比较独立的效果,这里我们新起一个Shader来完成这个效果,然后在OnRenderImage方法中Graphics.Blit两次 , 先完成刚刚一系列操作,再对结果进行径向模糊。
void Awake (){
    mySource = new RenderTexture(origin.width / 2, origin.height / 2, 0);
    RenderTexture.active = mySource;
    Graphics.Blit(origin, mySource);
}

void OnRenderImage(RenderTexture source, RenderTexture destination)
{
    ...//check material and set properties
    RenderTexture rt = RenderTexture.GetTemporary(source.width, source.height);
    Graphics.Blit(mySource, rt, colorMaterial);
    Graphics.Blit(rt, destination, blurMaterial);
    RenderTexture.ReleaseTemporary(rt);
} 具体的算法很简单。在模糊中心到该像素的方向上选取n个点,并将其求平均。
fixed4 frag (v2f i) : SV_Target
{
    fixed4 col = tex2D(_MainTex, i.uv);
    fixed4 sum = col;
    float2 dir = 0.5 - i.uv + float2(_CenterX,_CenterY);
    float dist = length(dir);
    dir /= dist;
    float samples =
    {
      -0.08,
      -0.05,
      -0.03,
      -0.02,
      -0.01,
      0.01,
      0.02,
      0.03,
      0.05,
      0.08
    };
    for (int it = 0; it < 10; it++) {
      sum += tex2D(_MainTex, i.uv + dir * samples * _SampleDist);
    }
    sum /= 11;
    float t = saturate(dist * _SampleStrength);
    float4 blur = lerp(col, sum, t);
    return blur;
}在时停的过程中,径向模糊的强度发生脉冲一样的变化,很有冲击感。
再见,谢谢所有的if

至此我们就粗略的完成了一个时停效果的制作啦。最后提一下如何优化我们的代码,将我们的老朋友if都改成step。
为什么说if不好呢?因为GPU是并行计算的,保证一样的执行顺序是有利于GPU的。而当你使用if时,同一段代码的运行顺序与时间就会随着情况的不同而发生变化,这是非常不适合并行计算的。
让我们从代码中最先出现if的地方开始
if ( d < _Radius * _Radius ){
    final = reversedColor;
}
else{
    final = col;
}代码很好理解。判断点是否在圆内,如果在里面,则反色。要把这个if干掉,首先我们来介绍一下我们的主人公:step
step这个函数其实很简单,看一下就明白啦
t1 = step(x , y);
t2 = x<y?1:0;
//t1等价于t2

t3 = step(0,0.5);
//t3 == 1;
t4 = step(1,0.5);
//t4 == 0; 简单来说,就是比较x是不是比y大,那我们要如何用这个函数来干掉if呢?
float rr = _Radius * _Radius;
half insideCircle = step(d, rr);
finalColor = lerp(col, reversedColor, insideCircle);嗯,对,就这样就完成了,大家可以验算一下,不一定要这样写,只要你能保证得到的结果与ifelse一致即可,十分的简单粗暴。下面我们来看一下if嵌套的情况:
if ( d < _Radius * _Radius ){
    final = reversedColor;
}
else{
    if(isGray)
      final = grayColor;
}可以看到,这里套了两层,那怎么来表示同时满足呢?利用逻辑&的性质,final = grayColor在两个step一个为真,一个为假的情况下执行,也就是step* (1- step)为真,于是上面的代码可以写成这样。
float rr = _Radius * _Radius;
half isGray = step(0.5, _Gray);
half insideCircle = step(d, rr);
finalColor = lerp(col, reversedColor, insideCircle);
fixed3 grayFactor = { 0.299,0.587,0.114 };
fixed grayColor = dot(grayFactor, col);
finalColor = lerp(finalColor, grayColor, isGray * (1-insideCircle));//step1 * (1-step2)大致就是这样,使用逻辑运算与多项式的组合达到目标效果。


好啦,就到这里了,详细工程文件与代码点击这里下载。之后可能会加一个mask让时停的发动者不为灰色。
这里也希望各路大神对我的方法提出评判指正,这是我第一次完整的去做一个效果,所以肯定有不少问题与错误…
最后回顾一下我们的效果:
下次见!
页: [1]
查看完整版本: Unity后处理实现The World时停效果