Unity Shader学习记录(五)

Unity Shader学习记录(五)

  在游戏中会有多种多样的渲染风格,有写实的,复古的,追求光影的,追求精细度的等等等等,但有一种渲染风格能为游戏营造一种完全不同的氛围,它不以追求真实性为目标,却反其道而行之,为玩家制造出一种容易辨识和更加夸张的感觉;这便是卡通风格渲染。
  说到卡通风格渲染,有很多典型的例子,比如军团要塞2,比如无主之地系列;它们使用卡通风格渲染为玩家营造的氛围就是一种荒诞和滑稽,却由于各自的游戏设计理念而更吸引玩家。
  那么卡通风格渲染是怎么做到的呢?


所谓卡通风格

  所谓的卡通风格渲染是个很有意思的概念,它本质上并不表示要对渲染过程做出多么大的变化,而是在渲染过程中对光影进行控制。其实卡通风格的特点很鲜明,它的光影效果都很“假”,换言之卡通风格中的光影效果要给人一种“画出来”的感觉;此外卡通风格也有自己的细分类型,有些卡通风格的光影显得很“硬”,光暗之间的切换是断崖式的,这是很典型的卡通风格;另外一些卡通风格的光影要软一些,光暗的切换带有渐变的色彩。
  为了营造这种“假”的光影效果,在Shader中就必须改变原来的光照运算过程;首先高光运算可以去除,漫反射运算也可以去除,使用一个渐变纹理来给定光照信息,半兰伯特光照模型来确定采样位置即可。
  一个可用的卡通风格渲染Shader代码如下。

Shader "Custom/RampDiffuseShader" {
    Properties {
        _Color ("Color", Color) = (1,1,1,1)
        _RampTex ("Ramp Map", 2D) = "white" {}
    }
    SubShader {
        Pass {
            Tags {"LightMode"="ForwardBase"}

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Lighting.cginc"

            float4 _Color;
            sampler2D _RampTex;
            float4 _RampTex_ST;
            float4 _Specular;
            float _Gloss;

            struct a2v {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
                float4 texcoord : TEXCOORD0;
            };

            struct v2f {
                float4 pos : SV_POSITION;
                float3 worldPos : TEXCOORD0;
                float3 worldNormal : TEXCOORD1;
            };

            v2f vert(a2v v) {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.worldPos = mul(unity_ObjectToWorld, v.vertex);
                o.worldNormal = UnityObjectToWorldNormal(v.normal);
                return o;
            }

            fixed4 frag(v2f i) : SV_TARGET {
                fixed3 worldNormal = normalize(i.worldNormal);
                fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
                float halfLambert = dot(worldNormal, worldLightDir) * 0.5 + 0.5;
                fixed3 ramp = tex2D(_RampTex, float2(halfLambert, halfLambert)) * _Color.rgb;
                return fixed4(ramp, 1.0);
            }

            ENDCG
        }
    }
    FallBack "Diffuse"
}

  注意到参数_RampTex,这个就是渐变纹理,它决定了材质的卡通光影渐变特性。
  Unity_Shader学习记录_figure_0
  如图所示就是一种渐变纹理的例子,在片元计算函数中,使用半兰伯特模型计算得到一个半兰伯特参数,在原本的光照模型中这个参数是用来参与漫反射颜色运算的;但在这里使用它作为纹理采样坐标对渐变纹理进行采样,这个操作就相当于将当前位置的光照强度映射到渐变纹理上,同时又因为渐变纹理的渐变方向是水平的,因此使用float2(halfLambert, halfLambert)这样的坐标就能得到所需的渐变颜色值,因为这个颜色仅取决于水平坐标。
  当然了,以上代码仅仅做到了卡通风格的光影效果,如果需要让物体有自己的纹理,只需要加上漫反射纹理采样结果并参与计算即可,其方法和正常的漫反射Shader一样。

fixed3 albedo = tex2D(_MainTex, i.uv.xy).rgb * _Color.rgb;
fixed3 ramp = tex2D(_RampTex, float2(halfLambert, halfLambert)) * albedo;
return fixed4(ramp, 1.0);

  事实上卡通风格的解决方案基本都是这样的原理,将光照结果映射到一张渐变纹理上,采样纹理颜色作为反射颜色,这样的“人造”反射光影恰好就对应上了卡通风格里那“很假”的光影效果。

卡通风格描边

  在卡通风格渲染中,描边是个很常见的效果,它能凸出某个渲染主体,让画面更具有绘画风格,更醒目。而事实上,描边效果应用非常广泛,并不只在卡通渲染中使用,大量的游戏使用描边效果来对玩家进行任务目标或者重要线索的提示。
  描边的原理相当简单,它就等同于要让模型的渲染结果向外拓展一定的宽度,并且这个拓展还具有特定的颜色。要达到这个效果至少有两种做法,直接Shader处理和后处理。
  直接Shader处理的思想是在顶点运算函数中将模型的顶点沿着法线方向向外拓展一定宽度,得到的结果作为顶点信息传入片元函数。这样一来单个Pass必然无法完成整个效果,因为必须有至少一个Pass用于渲染正常的物体,所以用两个Pass先后渲染拓展后的模型纯色图像和正常模型本身,后者覆盖前者即可达到效果。
  一个可用的Shader代码如下

Shader "Custom/OutlineShader" {
    Properties {
        _DiffuseColor ("Diffuse Color", Color) = (1,1,1,1)
        _OutlineColor ("Outline Color", Color) = (1,1,1,1)
        _OutlineWidth ("Outline Width", Range(0,0.1)) = 0.01
    }
    SubShader {
        Pass {
            Cull front
            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            fixed4 _OutlineColor;
            float _OutlineWidth;

            struct a2v {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
            };

            float4 vert(a2v v) : SV_POSITION {
                float3 exVert = v.vertex.xyz + _OutlineWidth * v.normal;
                return UnityObjectToClipPos(exVert);
            }

            fixed4 frag() : SV_TARGET {
                return _OutlineColor;
            }

            ENDCG
        }
        Pass {
            Tags {"LightMode"="ForwardBase"}
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Lighting.cginc"

            fixed4 _DiffuseColor;

            struct a2v {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
            };

            struct v2f {
                float4 pos : SV_POSITION;
                float3 worldNormal : TEXCOORD0;
                float3 worldPos : TEXCOORD1;
            };

            v2f vert(a2v v) {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.worldNormal = UnityObjectToWorldNormal(v.normal);
                o.worldPos = mul(unity_ObjectToWorld, v.vertex);
                return o;
            }

            fixed4 frag(v2f i) : SV_TARGET {
                fixed3 worldNormal = normalize(i.worldNormal);
                fixed3 worldLightDir = UnityWorldSpaceLightDir(i.worldPos);
                fixed3 viewDir = UnityWorldSpaceViewDir(i.worldPos);
                fixed3 diffuse = _LightColor0.rgb * _DiffuseColor.rgb * saturate(dot(worldNormal, worldLightDir));
                return fixed4(diffuse, 1.0);
            }
            ENDCG
        }
    }
    FallBack "Diffuse"
}

  在这个Shader中存在两个Pass,第一个Pass就是渲染被拓展后的模型纯色图,渲染结果会是一个稍大的纯色模型投影区域,注意到第一个Pass开启了Cull front,这是表示该Pass不渲染模型正面,只渲染背面;这很好理解,因为如果正面背面都被渲染了纯色图,则模型本身就被遮挡住了,什么都看不见。
  第一个Pass非常简单,就是在顶点计算过程中将每个顶点沿着法线向外拓展了一定的距离,而片元计算时则直接返回指定颜色;这种方法能看到描边效果,但是其效果却有瑕疵,包括描边存在近大远小的情况。
  造成近大远小的原因是在计算顶点位移时考虑了Z轴的位移,这样一来深度也就影响到了偏移量。那么想要去除这种影响,就必须对顶点计算过程进行小幅度的修改。

float4 vert(a2v v) : SV_POSITION {
    float4 pos = UnityObjectToClipPos(v.vertex);
    float3 vNormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
    float2 offset = TransformViewToProjection(vNormal.xy);
    pos.xy += offset * _OutlineWidth;
    return pos;
}

  经过这样的修改后,偏移只针对X-Y平面,就不再受到Z轴影响了。
  第二个Pass是正式渲染模型本身,在这里使用了最简单的单色漫反射。
  两个Pass写好后,渲染过程会按照从上到下的顺序运行每个Pass,于是就得到了一个有描边的物体图像,可以通过调整材质上的描边宽度参数来控制描边的效果。
  这样一来描边确实是有了,但如果多做点测试,仔细观察可以发现,这样的描边策略在大部分时候没什么问题,可一旦遇到两个平面的硬连接,也就是法线突变的情况下,会看到明显的断裂效果;比如在正方体上使用该Shader,转动时可以看到描边位置漂浮在物体平面的上方,非常别扭。
  原因很简单,顶点运算时沿着法线偏移顶点,实际上就是将模型的面沿着法线向外位移,如果模型的各个面之间过渡比较平滑,则这种位移造成的撕裂效果会比较轻,在描边宽度值合适的时候不容易看出来;可一旦模型上的面与面之间过渡很硬,比如正方体这种90度突变,则位移造成的撕裂会非常明显,无论多小的描边宽度看起来都很明显。
  后处理方案可以一定程度上缓解这种问题,但它也有自己的缺点,首先它的原理是基于RenderTexture,这也就表示这个方案在处理过程中需要将当前的画面渲染成一张纹理并传送给显卡进行后续渲染,效率是必然受到影响的;其次如果想要做出柔化描边的效果就需要引入模糊,这会对本身就因为多处理一张纹理而导致的效率问题进一步恶化,因此后处理方案在移动平台表现并不尽如人意。
  后处理方案涉及到RenderTexture,因此代码较直接Shader方案要复杂得多,需要C#脚本以及至少两个Shader配合,如果需要实现模糊还必须在片元运算中执行模糊算法。
  关于该方案的实现可以参考博文Unity Shader-描边效果

Shader中的透明处理

  在Shader中处理透明物体是一个需要仔细斟酌和编写的部分,因为现代计算机显示设备的渲染几乎总是基于虚拟物体的深度进行的,换言之在物体的颜色被渲染出来之前就预先估计了一个物体是否会显示在画面上。
  这固然是一种很有效的优化方案,但当环境中存在透明乃至半透明物体时,这样的方法很容易造成渲染效果出错,透明物体与非透明物体之间的遮挡关系,半透明物体的透视效果等等,这都是需要思考和处理的问题。
  要了解Unity中Shader对透明物体的渲染情况,有几个基本概念是要先知道的。

  • 渲染顺序
  • 渲染队列
  • 透明度测试
  • 透明度混合

  其中渲染顺序指的是渲染引擎如何按照顺序渲染场景中的物体,这一点在不存在任何透明物体时几乎无关紧要,因为大部分引擎均使用深度写入技术判定物体的先后顺序,并且通过先后顺序对场景渲染进行了预先裁剪提高效率,这些过程都不是太需要开发人员的关注。
  但如果场景中存在透明或者半透明物体,那么深度写入可能会成为障碍,而一旦有必要关闭深度写入,那么开发人员就必须小心地对待渲染队列,否则将会得到错误的渲染结果。
  渲染队列是Unity为了解决渲染顺序问题提供的解决方案,它类似于一种手动指定物体渲染顺序的做法,但同时也保留了同一个队列中物体按照默认方式排序的特点。
  透明度测试是一种很僵硬的透明效果处理,它需要指定一个阈值,透明度在阈值以上的则认定为完全透明,在阈值以下的则认为完全不透明。这种非此即彼的做法虽然速度快而且容易理解,可是效果太差几乎难以满足要求。

fixed4 frag(v2f i) : SV_TARGET {
    ...
    clip(texColor.a - CutOff);
    ...
}

  简单的一个clip方法即可实现透明度测试,它会将参数小于0的片元直接剔除不予渲染,结果就是会在模型表面留下一个个的空洞。
  而透明度混合就不同于透明度测试,它是真正意义上考虑了半透明的情况,它会读出纹理中每个坐标点的透明数值,混合到渲染结果的颜色中,因此能做到很符合要求的半透明效果。
  要使用透明度混合就必须按照Unity的要求打开它,使用的关键字是Blend。

Pass {
    ...
    Blend SrcAlpha OneMinusSrcAlpha
    ...
}

  这样一来,Shader只需要在片元处理中返回带透明度的颜色,Unity自然会将该颜色与它背后的颜色混合起来,得到结果;Blend后面带着的是两个参数,分别描述混合时源颜色与混合目标的原本颜色通过何种公式进行结合,上文中的参数表示如下的计算公式

DstColotnew=SrcAlpha×ScrColor+(1SrcAlpha)×DstColorold

  针对透明度混合这种方式,除了需要Blend关键字打开混合模式之外,还有几个重点。
  首先是渲染队列,Unity规定了透明度混合所使用的渲染队列,因此需要在SubShader中的Tags标签内写明混合Pass位于Transparent队列中。

SubShader {
    Tags {"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent"}
    ...
}

  同时还有IgnoreProjector标签设定,这里表示不使用投影器,这是透明度混合模式的通常标签设置。
  然后是关闭深度写入,就如前文所说,这是为了让透明度混合能正确处理不同物体的遮挡问题。

ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha

  用Blend关键字打开混合模式后,在片元处理函数中就可以按照纹理的透明度计算出最终颜色的透明度,加入返回的颜色通道里了。

fixed4 texColor = tex2D(_MainTex, i.uv);
return fixed4(diffuse, texColor.a * _AlphaScale);

  需要注意的是,返回颜色的透明通道必须在打开了混合模式的前提下才能正确生效,否则渲染结果依然没有任何透明效果。
  在实践中可以发现,虽然这样的Shader能实现半透明效果的渲染,但由于关闭了深度写入,导致其自身的复杂遮挡关系将无法正确展示。如果将这样的Shader应用于一个扭曲的,自身就有很多遮挡情况的复杂物体,那么渲染结果将会显得很不正常。
  想要比较完美地解决这种不正常的遮挡,分割网格使得物体自身的遮挡关系简化将会是一种可能的选择,但实践过程中这种做法并不总是实际有用而且高效;因此还存在一种“折中”的办法,那就是重新利用好深度写入来实现物体的整体性透明。
  这种方法的思想并不复杂,使用两个Pass处理渲染流程,第一个Pass仅开启深度写入,不写入任何颜色,换句话说只记录当前物体的深度信息;第二个Pass再关闭深度写入按照透明度混合的方式进行渲染,两个Pass过后就能让一个复杂物体的整体拥有半透明效果。
  代码大致如下所示

SubShader {
    Tags {"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent"}
    Pass {
        ZWrite On
        ColorMask 0
    }
    Pass {
        // 透明度混合代码
    }
}

  应用了这样的Shader后,复杂物体内部自身的遮挡关系将变得和不透明时一样,而整体却拥有了可设置的透明度。
  注意到第一个Pass中的ColorMask关键字,它的作用是声明当前Pass写入的颜色通道,可以有多个值,包括R,G,B,A的任意组合或者0,而0则表示不写入任何颜色通道。
  Unity使用ShaderLab作为标准Shader的抽象工具,因此它提供了一些方便使用的透明度混合方法,比如Blend关键字,又比如BlendOp指令,后者较Blend关键字而言更加方便,但灵活度也有所下降,关于BlendOp具体支持的混合操作可参考Unity官方说明。
  透明度是Shader中需要谨慎对待的一种效果,尤其是同时涉及遮挡,阴影投射,光照等情况时,透明和半透明物体的处理必须仔细斟酌。
  之后将会解析一种有些特别的纹理类型以及复杂光照的Shader处理。

猜你喜欢

转载自blog.csdn.net/soul900524/article/details/79522804