制作出科幻效果的着色器教程 VR资源

音速键盘喵 2018-01-09 16:34:08
本文将介绍如下几种特效:
 

Inking (模型描边)Hologram (模型的全息图)See - Through (渲染出物体被遮挡的部分, 类似于穿墙透视效果 - 屏幕后期特效)Force Field (力场护盾效果)Video Glitch (模拟LCD显示屏受到电子干扰的效果 - 屏幕后期特效.

Inking (模型描边, Outline)

何为Inking?
 
Inking是附加在蒙皮网格上的模型特效, 它用比较细的灰黑色的线条勾勒出网格的轮廓. 这样做的好处是能够从背景更加清晰地勾画出这个网格, 尤其是在对比度比较低的区域中. Inking特效的应用场景特别多, 大家耳熟能详的LOL中就出现了它的踪影:
 
  
 
 
第一张图中是原图, 而第二张图是加入Inking特效后的结果. 我们看到, 加入Inking后, 所有的模型能够更容易地从背景中区分出来, 起到了Bump Up的作用. 这个例子中使用了比较粗的Inking线条, 这样也增添了一分漫画风格的质感.
 
Inking的实现方法(综述)
 
Inking的实现方法有很多种, 大体上可以分为操作点元和操作片元两大类. 视具体情况决定使用哪一种Inking:
 
  • Fresnel(菲尼尔)方法 - 非常类似于Rim Lighting, 使用视线方向和点法线方向的点积来判断边缘, 并将边缘高亮化.
     
 
  • 优点: 效率高; 不需要单独的Pass就可以实现; 几乎所有的平滑的边缘都会得到高亮效果; 甚至对透明和半透明物体也有效. 缺点: 无法控制Inking线条的粗细, 这是因为Fresnel方法是针对于模型法线和摄像机视线的, 从而导致其仅与每个表面的法线方向有关, 而与表面的深度信息无关.
 
 
  • Mesh Doubling (复制网格) - 非常类似于卡通Toon特效. 需要一个单独的Pass来实现. 重新绘制一个将所有表面都沿着法线方向延展过的模型, 然后将正面剪裁掉. 这也是我采用的方案
     
  • 优点: 效率高; 平台适应性好; 可以控制Inking的线条粗细. 缺点: 线条并不连续, 在平滑表面的表现虽然很好, 但是在锐利的表面上经常会出现断层; 只能绘制最外层轮廓, 而不对内部结构做任何处理.
     
 
 
  • Edge Detection (边缘检测) - Unity自带的屏幕后期处理特效[2]. 使用Sobel Filter[3]进行描边的算法, 其基本原理是检测多个相邻的像素的深度差值, 使用一个3x3的采样块来对原图求卷积, 将深度信息差值比较大的部分过滤出来. LOL中的Inking使用的就是这个方法.

     
  • 优点: 既可以用作屏幕后期特效, 又可以作为模型特效; 描边准确; 线条粗细可控. 缺点: 比上述两种方案都要昂贵得多, 但是其性能开销恒定, 与被处理的图像没有任何关系;

     

  • 使用几何着色器 - 检验临近的多边形以确定邻边和夹角, 再单独构建轮廓的几何体.

     
  • 优点: 目前为止最为精确的做法; 很容易控制线条的粗细. 缺点: 建议买一台给力点的工作站或服务器; 一般只能用于离线渲染;

     
具体实现策略
 
采用了Mesh Doubling (复制网格)的方法. 这里必须要解决的问题是线条的不连续性. 其思路是不严格地将表面沿着法线方向延展, 而是在标准化的点元位置和法线方向之间取一个恰当的参数来做插值, 这样做的好处是表面在延展的过程
 
中也会尽量向点元方向靠拢, 尽量地减少了新网格的撕裂感.
 
 
其中, L表示偏移向量; W表示轮廓线条粗细; D是物体和摄像机间的距离. V是标准化后的顶点坐标, 表示方向; N是顶点向量; f是插值参数.


上图更加清晰地阐述了撕裂和不连续的情况. 如果不进行插值, 那么这种方法可以适用于球形等表面变化均匀且光滑的几何体, 但是对于立方体则无能为力.
 

上图中的立方体的延展向量使用了参数0.032作为插值, 撕裂感便不复存在了.
 
这里给出Inking特效的核心程序代码(非常简短), 同时附上部分Implementation Notes:
 
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
vertexOutput vert ( appdata_base v )
{
                vertexOutput o;
           
                o.pos = mul ( UNITY_MATRIX_MVP, v.vertex );
                float3 dir = normalize ( v.vertex.xyz );
                float3 dir2 = v.normal;
                 
                dir = lerp ( dir, dir2, _Factor );
                dir = mul ( ( float3x3 ) UNITY_MATRIX_IT_MV, dir );
                float2 offset = TransformViewToProjection ( dir.xy );
                offset = normalize ( offset );
                float dist = distance ( mul ( UNITY_MATRIX_M, v.vertex ), _WorldSpaceCameraPos );
                o.pos.xy += offset * o.pos.z * _OutlineWidth / dist;
  
                return o;
}
_Factor即为插值参数. 变换法向量要注意使用Model View矩阵的转置逆矩阵. 为了保证最终的线条粗细维持世界坐标上的恒定, 而不随摄像机的移动发生改变, 因此延展的像素位置要除以摄像机距离. 最终片元着色器函数只需要一句
 
return _Color;即可. 最后必须注意剪裁掉正面, 否则绘制出的不会是轮廓, 而是将模型包裹起来的保鲜膜
 
  

上面两张图是加入特效的前后对比. 我们看到使用Inking后炮塔能够更加"犀利"地从背景中呈现出来.
 
一定程度上, Inking有点类似于SSAO. 两者都是尝试在几何体的交界处加入更深层次的阴影以让画面更有对比度.
 

Hologram (模型的全息图)

何为Hologram?
 
全息图是一般以激光为光源, 将被摄物体记录为3D光场(Light Field)所构成的三维图像. 一般以干涉条纹的形式存在.
 
  

上图中第一张图是全息投影仪的概念效果, 第二张图是质量效应(Mass Effect)中的特效.
 
Hologram一般可以用作单位建造的预览效果和呈现结构的效果.
 
  


第一次尝试(Naive 方法)
计算模型的每个片元的屏幕坐标, 然后对一个条纹状纹理采样即可. 为了防止单一的条纹过于无聊, 同时还引入了一个Noise Map来进行干扰. 代码如下:
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
v2f vert ( appdata_base v )
{
    v2f o;
    o.pos = UnityObjectToClipPos ( v.vertex );
    o.uv = v.texcoord.xy;
    o.screenPos = ComputeScreenPos ( o.pos );
    o.dist = distance ( mul ( UNITY_MATRIX_M, float4 ( 0.0, 0.0, 0.0, 0.0 ) ), _WorldSpaceCameraPos );
    return o;
    }
fixed4 frag ( v2f i ) : COLOR
{
    fixed4 finalColor;
    float2 uvNormal = UnpackNormal ( tex2D ( _NormalTex, i.uv ) ) / i.dist;
    float2 screenUV = ( i.screenPos.xy / i.screenPos.w + float2 ( _TilingX * _Time.y, _TilingY * _Time.y ) ) * i.dist * _Distance;
    fixed3 color = _Color * tex2D ( _MainTex, screenUV + uvNormal ) * _Emission;
    fixed alpha = _Color.a * max ( min ( color.r, color.g ), color.b );
    return fixed4 ( color, alpha );
}
 
得到的结果自然也是Naive的
 
 

对比上面的两张效果图, 我们发现一个问题: 虽然当前的这个Hologram特效能够显示出干涉条纹, 但是整个物体的深度和法线信息全部丢失, 给人以一种乱糟糟的线条感.
 

第二次尝试(将深度和法线信息纳入考量)

仔细观察前面的两张效果图, 我们看到dot(viewDirection, normalDirection)(以下简称为点积)越大则越昏暗. 为了让整个特效更加有层次感, 我选择的方案是分别计算点积大的区域和点积小的区域来的颜色信息. 同时我更新了对干涉条纹的计算方法: 为了防止整个全息图特效的颜色过淡, 先给予一个统一的强度_Strength, 然后再加上对干涉条纹的采样值.
 
 
 
 
 

得到的结果如下:
 
 

着色器代码如下:
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
v2f vert ( appdata_base v )
    {
        v2f o;
 
        o.pos = UnityObjectToClipPos ( v.vertex );
 
        o.projPos = ComputeScreenPos ( o.pos );
 
        o.uv = v.texcoord.xy;
 
        o.normalDir = UnityObjectToWorldNormal ( v.normal );
 
        o.posWorld = mul ( UNITY_MATRIX_M, v.vertex );
 
        return o;
    }
 
    fixed4 frag ( v2f i ) : COLOR
    {
        fixed alpha = 1;
        float sceneZ = LinearEyeDepth ( SAMPLE_DEPTH_TEXTURE_PROJ ( _CameraDepthTexture, UNITY_PROJ_COORD ( i.projPos ) ) );
        float partZ = i.projPos.z;
        float fade = saturate ( _InvFade * ( sceneZ - partZ ) );
        alpha *= fade;
                 
        float3 viewDirection = normalize ( _WorldSpaceCameraPos.xyz - i.posWorld.xyz );               
                  
        float4 objectOrigin = mul ( unity_ObjectToWorld, float4 ( 0.0, 0.0, 0.0, 1.0 ) );
 
        float dist = distance ( _WorldSpaceCameraPos.xyz, objectOrigin.xyz );
 
        float2 wcoord = i.projPos.xy / i.projPos.w;
                wcoord.x *= _Inter.y;
        wcoord.y *= _Inter.z;
        wcoord *= dist * _Inter.x;
         
        float3 nMask = _Strength;
                  
        float3 hMask = tex2D( _MainTex, wcoord + float2 ( 0, _Time.x * _Inter.w ) );
 
        float fresnel = pow ( abs ( dot ( viewDirection, i.normalDir ) ), _FresPow ) * _FresMult;
        float3 bLayer = lerp ( _bLayerColorA, _bLayerColorB, fresnel );
 
        float fresnelOut = pow ( 1 - abs ( dot ( viewDirection, i.normalDir ) ), _FresPowOut ) * _FresMultOut;
        float3 bLayerC = _bLayerColorC * fresnelOut;
 
        float3 final = saturate ( ( hMask + nMask ) * ( bLayer + bLayerC ) ) * alpha;
             
        return float4 ( final * _Fade, 1) ;
    }
 
See - Through(透视特效)
 
何为透视特效?


游戏中总有一些非常重要的物体, 需要确保玩家在任何时候都能以某种方式看到. 比如Hitman系列中玩家可以使用这种透视的方式来知道敌人的位置以确定自己的战术. 而RTS类游戏(比如红色警戒3)中被遮挡的单位也会以另一种颜色被渲染出来, 防止玩家不知道其存在.
 
如何实现透视特效
将游戏物体分为两层: Occluder(遮挡)层和Behind(后面)层. 特效要实现的目标是将Behind层被Occluder层遮挡的部分渲染出来. 这里使用两个摄像机, 分别渲染两个层的深度信息, 得到两张Render Target(以下简称RT). 将所有Behind RT深度大于对应Occluder RT深度的部分以另一种方式渲染出来, 而不对其余部分做任何处理, 并将结果放到一个新的RT中. 最终画一个全屏幕的Quad, 将这个RT直接Apply即可.
 
 
 
为了增加渲染结果的层次感并反应被遮挡物体的结构, 渲染Behind层的摄像机可以同时渲染法线信息, 然后在渲染最终RT的时候将颜色强度与法线方向挂钩即可.
 
以下是渲染结果:
 

如何让Rendering Path为Forward的摄像机得到场景的深度和法线信息呢? 使用单独的着色器规定其渲染行为, 然后使用Camera.RenderWithShader即可.
规定摄像机渲染方式的着色器:
 
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
struct v2f
{
        float4 pos : POSITION;
        float4 nz : TEXCOORD0;
};
             
v2f vert( appdata_base v )
{
        v2f o;
        o.pos = mul( UNITY_MATRIX_MVP, v.vertex );
        o.nz.xyz = COMPUTE_VIEW_NORMAL;
        o.nz.w = COMPUTE_DEPTH_01;
        return o;
}
             
fixed4 frag( v2f i ) : COLOR
{
        return EncodeDepthNormal ( i.nz.w, i.nz.xyz );
}
 
处理两张RT的着色器:
 
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
v2f vert ( appdata_img v )
{
        v2f o;
        o.pos = UnityObjectToClipPos ( v.vertex );
        o.uv = v.texcoord.xy;
        return o;
}
fixed4 frag ( v2f i ) : COLOR
{
        float behindDepth, occluderDepth;
        float3 behindNormal, occluderNormal;
        DecodeDepthNormal ( tex2D ( _Behind, i.uv ), behindDepth, behindNormal );
        DecodeDepthNormal ( tex2D ( _Occluder, i.uv ), occluderDepth, occluderNormal );
        fixed4 scene = tex2D ( _MainTex, i.uv );
        fixed4 pattern = tex2D ( _PatternTex, ( i.uv + _SinTime.w / 100 ) / _PatternScale );
        if (behindDepth > 0 && occluderDepth > 0 && behindDepth > occluderDepth)
        {
                float factor = 0.1 + 0.9 * pow ( max ( dot ( float3 ( 0, 0, 1 ), behindNormal ), 0.0 )