游戏特效第五期 - 几种科幻风格的着色器特效

Hacker F-301中的特效实现方案分享

作者 Jingping Yu 日期 2017-03-12

本文同时也发表在我的知乎专栏 - 大萌喵的Shader相册上😄.

作者:音速键盘猫

链接:https://zhuanlan.zhihu.com/p/25725870

来源:知乎

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

写在前面

最近闲来无事, 因此给自己开了个新坑 - 为一款科幻风格的塔防游戏Hacker F-301(骇客F-301)编写着色器特效. 在这个过程中有了一些心得体会, 因此打算写出来分享. 希望大家多提宝贵意见.

Topics

本文将介绍如下几种特效:

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

Inking (模型描边, Outline)

何为Inking

Inking是附加在蒙皮网格上的模型特效, 它用比较灰黑色的线条勾勒出网格的轮廓. 这样做的好处是能够从背景更加清晰地勾画出这个网格, 尤其是在对比度比较低的区域中. Inking特效的应用场景特别多, 大家耳熟能详的LOL中就出现了它的踪影:

上面的两张图来源于一篇对LOL渲染流程分析的博客[1]. 第一张图中是原图, 而第二张图是加入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 (复制网格)的方法. 这里必须要解决的问题是线条的不连续性. 其思路是不严格地将表面沿着法线方向延展, 而是在标准化的点元位置和法线方向之间取一个恰当的参数来做插值, 这样做的好处是表面在延展的过程中也会尽量向点元方向靠拢, 尽量地减少了新网格的撕裂感.

$$ P_{new} = P_{old} + L \times W_{outline} / D_{cam} $$

$$ L = Normalize(MV_{IT} \times lerp(V, N, f)) $$

其中, L表示偏移向量; W表示轮廓线条粗细; D是物体和摄像机间的距离. V是标准化后的顶点坐标, 表示方向; N是顶点向量; f是插值参数.

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

上图中的立方体的延展向量使用了参数0.032作为插值, 撕裂感便不复存在了.

这里给出Inking特效的核心程序代码(非常简短), 同时附上部分Implementation Notes:

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. 两者都是尝试在几何体的交界处加入更深层次的阴影以让画面更有对比度. 关于SSAO我的知乎专栏第四篇有过介绍.

Hologram (模型的全息图)

何为Hologram

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

上图中第一张图是全息投影仪的概念效果, 第二张图是质量效应(Mass Effect)中的特效.

Hologram一般可以用作单位建造的预览效果呈现结构的效果.

第一次尝试(Naive 方法)

计算模型的每个片元的屏幕坐标, 然后对一个条纹状纹理采样即可. 为了防止单一的条纹过于无聊, 同时还引入了一个Noise Map来进行干扰. 代码如下:

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.00.00.00.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, 然后再加上对干涉条纹的采样值.

$$ \alpha = F_{soft} \times (Z_{scene} - Z_{object}) $$

$$ S_{edge} = (1 - abs(N \cdot V)) ^ {I_{edge}} \times M_{edge} $$

$$ S_{surf} = (abs(N \cdot V)) ^ {I_{surf}} \times M_{surf} $$

$$ C_{hologram} = tex(T_{main}, wcoord + UV_{time}) $$

$$ C = \alpha * (S_{edge} + S_{surf}) * (C_{normal} + C_{hologram}) $$

得到的结果如下:

着色器代码如下:

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.00.00.01.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即可.

规定摄像机渲染方式的着色器:

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的着色器:

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 ( 001 ), behindNormal ), 0.0 ), 1.2 );
return fixed4 ( lerp ( scene, _Color, lerp ( factor, factor * pattern.r, _PatternWeight ) ) );
}
  else
return scene;
}

一个小问题

这个实现的方式开销有点大: 对于每一组Occluder层和Behind层, 都需要用两个摄像机单独渲染一次(虽然只需要深度和法线, 不需要计算光照), 然后做一次全屏幕的后期特效. 那么对于像红色警戒3这样的游戏, 总共有6个阵营的单位, 而每个阵营的单位被遮挡后都要被渲染为不同的颜色. 如果采用我的方法的话, 那就相当于要用7个(6 * Behind + 1 * Occluder)摄像机加6层后期特效, 显然在性能上是不可能的.

根据我的猜测, 红色警戒3应该是为每个单位额外附加了一个材质, 这个材质只有ZTest Fail的时候才会被渲染. 不知道这个猜测是否正确, 希望能与大家交流.

ForceField (护盾特效)

何为ForceField

上图为TitanFall 2的截图. 图中泰坦的周围有一层半球形力场护盾. 如果有子弹打击到护盾上, 则对应被击中位置还会产生纹理上的变化, 并扭曲背景.

我们注意到这个ForceField与其他物体相交的位置都有高亮的特效. 关于相交高亮(Intersection Highlight)我的专栏第二篇文章有过介绍.

如何实现ForceField

实现护盾本身并不难 --- 只需要一个半透明的材质就足够了. 难的是如果护盾被击中的话, 需要在被击中的位置产生变化.

为了解决这个问题, 我们必须将碰撞点的位置从世界坐标转换到模型坐标. 然后根据每个点元和碰撞点的距离来决定动态纹理的Alpha值即可.

$$ \alpha = A_{init} + \sum _{i = 1} ^{n} \frac {Dist_{i}} {Dist_{max}} \times B $$

为了能够支持多个碰撞点, 可以在着色器中使用n个4*4矩阵以代表4 * n个碰撞点. 每个片元遍历一次所有的碰撞点信息即可. 护盾被击中以后, 其动态纹理的影响会随着时间逐渐减少, 因此动态纹理的Alpha也需要随着时间减弱(见下图).

时间可以通过脚本或着色器来控制. 只需要注意时间非负即可.

以下是着色器代码:

v2f vert ( appdata_base v )
    {
        v2f o;
        v.vertex += float4 ( v.normal * _MeshOffset, 0.0 );
        o.pos = UnityObjectToClipPos ( v.vertex );
        o.uv = v.texcoord.xy;
        float3 worldPosition = mul ( UNITY_MATRIX_M, v.vertex );
        float3 viewDirection = normalize ( worldPosition - _WorldSpaceCameraPos );
        o.factor =  ( dot ( UnityObjectToWorldNormal ( v.normal ), viewDirection ) );
        for ( int ii = 0; ii < 4; ii++ )
        {
            o.dist[ ii ] = distance ( _CollisionPoints[ ii ].xyz, v.vertex.xyz );
        }
        return o;
    }
    fixed4 frag ( v2f i ) : COLOR
    {
        fixed4 finalColor;
        float2 uvNormal = UnpackNormal ( tex2D ( _NormalTex, i.uv * _NormalScale + float2 ( _TilingX * _Time.y, _TilingY * _Time.y ) ) );
        fixed3 color = tex2D ( _MainTex, ( i.uv ) * _MainScale + uvNormal ) * _Color * _Emission;
        float fallOff = saturate ( pow ( 1.0 - i.factor, _FallOff ) * pow ( i.factor, _FallOff2 ) );
        ///Magic Number! 
        half alpha = 0.01;
        alpha += saturate ( pow ( _CollisionTime.x, 0.5 ) - ( float ( i.dist[ 0 ] ) / _MaxDistance ) ) * _BrightnessCollision * max ( sign ( _CollisionTime.x ), 0.0 );
        alpha += saturate ( pow ( _CollisionTime.y, 0.5 ) - ( float ( i.dist[ 1 ] ) / _MaxDistance ) ) * _BrightnessCollision * max ( sign ( _CollisionTime.y ), 0.0 );
        alpha += saturate ( pow ( _CollisionTime.z, 0.5 ) - ( float ( i.dist[ 2 ] ) / _MaxDistance ) ) * _BrightnessCollision * max ( sign ( _CollisionTime.z ), 0.0 );
        alpha += saturate ( pow ( _CollisionTime.w, 0.5 ) - ( float ( i.dist[ 3 ] ) / _MaxDistance ) ) * _BrightnessCollision * max ( sign ( _CollisionTime.w ), 0.0 );
        finalColor.rgb = color;
        finalColor.a = alpha * pow ( finalColor.b, 2.0 );
        return finalColor;
    }

以下是用于计算碰撞点模型空间坐标并为着色器赋值的脚本:

public void ShieldOnWorldSpacePoint (Vector3 point)
{
Vector3 localPosition = transform.InverseTransformPoint (point);
Vector4 toShield = new Vector4 (localPosition.x, localPosition.y, localPosition.z, 1f);
effectTime[currentIndex] = duration;
collisionPoints.SetRow(currentIndex, toShield);
material.SetMatrix("_CollisionPoints", collisionPoints);
material.SetVector("_CollisionTime", effectTime);
currentIndex++;
currentIndex %= 4;
}

我这里总共使用了一个4*4的矩阵, 因此可以用来表示4个碰撞点. 这实际上已经非常足够了, 因为我们可以将每个碰撞的持续时间缩短, 做到及时更新. 根据我的观察, TitanFall2中的Doom Shield应该也只使用了最多8个碰撞点. 我曾经用Legion(就是拿重机枪的那个, 一秒钟能打十几发子弹)泰坦扫射对面的Doom Shield, 感觉最多也只有6, 7个碰撞点的样子.

我这里的实现方案没有引入HeatWave, 也就是说护盾被击中后不会扭曲后面的背景. 这是因为在这款游戏中护盾是加在防御塔上的, 而防御塔占据整个屏幕的空间比较小, 因此并不需要将效果做得很精致; 而因为防御塔的数量可能比较多, 因此性能是更大的考量, 所以采用了直接Blend的方式. 如果想要扭曲背景的话, 则使用GrabPass[4], 然后为uv添加一层Bump Map过滤即可[5].

Video Glitch (干扰特效)

在TitanFall 2中, 游戏以让画面呈现电子干扰的方式来通知玩家泰坦正在承受强大的火力攻击.

在Alienation中, 游戏主界面也会不时地发生类似LCD显示屏模拟信号受到干扰的效果. (此处无图, 因为我在PS4上的截图无论如何也导不出来)

这种干扰特效归根到底可以分为两部分:

  1. 画面的扭曲.
  2. 颜色漂移.

其实这两步的实现都非常简单: 画面的扭曲可以通过更改采样的UV坐标来实现, 而颜色漂移则可以用另一UV坐标采样单独的某个颜色通道, 然后作为输出的颜色中对应的通道.

代码如下:

fixed4 frag ( v2f i ) : COLOR
    {
        float u = i.uv.x;
        float v = i.uv.y;
        float horizonNoise = tex2D ( _NoiseTex, float2 ( v, _Time.x ) ) * 2 - 1;
        horizonNoise *= step ( _Threshold, abs ( horizonNoise ) ) * _Amount;
        float shake = ( tex2D ( _NoiseTex, float2 ( _Time.x, 2 ) - 0.5 ) ) * _Shake;
        float drift = sin ( v + _DriftTime ) * _DriftAmount;
        half4 color = tex2D ( _MainTex, frac ( float2 ( u + jitter + shake, v ) ) );
        half4 color1 = tex2D ( _MainTex, frac ( float2 ( u + jitter + shake + drift, v ) ) );
        return half4 ( color.r, color.g, color1.b, 1 );
    }

最终效果如下:

后记

以上就是我近几天做的特效. 代码我会尽快上传到Github上, 到时大家多提宝贵意见. 如果有需要完善的地方敬请Raise Issues.

PS : 大三学生党, 实习工作快到碗里来 ~

参考

[1] A Trip Down The LOL Graphics Pipleline (JAN 12, 2017), https://engineering.riotgames.com/news/trip-down-lol-graphics-pipeline Retrieved 19:24, March 12, 2017, from Riot Games Engineering.

[2] https://docs.unity3d.com/Manual/script-EdgeDetectEffect.html

[3] Sobel operator. (2017, March 5). In Wikipedia, The Free Encyclopedia. Retrieved 01:45, March 13, 2017, from https://en.wikipedia.org/w/index.php?title=Sobel_operator&oldid=768707694

[4] https://docs.unity3d.com/Manual/SL-GrabPass.html

[5] https://blogs.unity3d.com/cn/2011/09/08/special-effects-with-depth-talk-at-siggraph/