本系列为作者学习UnityShader入门精要而作的笔记,内容将包括:
- 书本中句子照抄 + 个人批注
- 项目源码
- 一堆新手会犯的错误
- 潜在的太监断更,有始无终
总之适用于同样开始学习Shader的同学们进行有取舍的参考。
卡通风格渲染
卡通风格的渲染特点往往是:黑色线条的描边,分明的明暗变化(色调变换不是渐变的,而是不同的颜色)
要实现卡通风格的渲染,其中之一就是使用基于色调的着色技术,在7.3部分我们使用渐变纹理实现了这样的效果。卡通的高光效果也和我们之前学习的光照不同,在卡通风格中,模型的高光往往是一块块分界明显的纯色区域。
之前我们是直接通过屏幕后处理卷积计算边缘并进行描边,现在,我们将学习如何基于模型进行描边。
渲染轮廓线
在RTR3中,作者将轮廓线的渲染分成了5类:
- 基于观察角度和表面法线的轮廓线渲染。这种方法使用视角方向和表面法线的点乘结果来得到轮廓线的信息,
- 过程式几何轮廓线渲染,使用两个Pass渲染,第一个Pass渲染背面的面片,并用某些技术使其轮廓可见,第二个Pass再正常渲染正面的面片,这种方法的优点在于快速有效。并且适用于绝大多数表面平滑的模型,但它的缺点是不适合类似于立方体这样平整的模型
- 基于图像处理的轮廓线渲染,这种方法可以适用于任何种类的模型,但是一些深度和法线变化很小的轮廓无法被检测。
- 基于轮廓边缘检测的轮廓线渲染,上述这些方法的缺点在于无法控制轮廓线的风格渲染,如果我们想要渲染出特殊风格的轮廓线,例如水墨风格等。就需要检测出精确的轮廓边,然后直接渲染它们。检测一条边是否是轮廓边的公式很简单,我们只需要检查和这条边相邻的两个三角形面片是否满足以下条件:
( n 0 ⋅ v > 0 ) ≠ ( n 1 ⋅ v > 0 ) (n_0 \cdot v >0) \neq (n_1 \cdot v > 0) (n0⋅v>0)=(n1⋅v>0)
其中 n 0 & n 1 n_0 \& n_1 n0&n1分别代表了两个相邻三角形面片的法向,v是从视角到该边上任意顶点的方向。上述公式的本质在于检测两个相邻的三角形面片是否一个朝向正面,一个朝向背面。我们可以在几何着色器的帮助下实现上面的检测过程。缺点是实现相对复杂,且由于是逐帧单独提取轮廓,所以帧与帧之间会出现跳跃性,会导致动画连贯性的问题。
- 最后一种方法混合了上述几种的渲染方法,例如先找到精确的轮廓边,把模型和轮廓边渲染到纹理中,再使用图像处理的方法识别出轮廓线,并在图像空间下进行风格化渲染,我们将使用两个Pass——第一个Pass使用轮廓色渲染背面面片,并在视角空间下把模型顶点沿着法线方向向外扩张一段距离,以此让背部轮廓线可见:
viewPos += viewNormal * _Outline;
随后用正面面片遮住不必要显示的背面,保留的部分就是轮廓线了
但是如果直接使用顶点法线进行拓展,对于一些内凹的模型,就可能产生背面面片遮挡正面面片的情况
为了尽可能防止出现这样的情况,在扩张背面顶点之前,我们首先对顶点法线的z分量进行处理,使其等于一个定值,然后把法线归一化之后再对顶点进行扩张,好处在于,拓展后的背面更加扁平化,从而降低了遮挡正面面片的可能:
viewNormal.z = -0.5;
viewNormal = normalize(viewNormal);
viewPos = viewPos + viewNormal * _Outline;
添加高光
卡通的高光往往是模型上一块块分界明显的纯色区域,为了实现这种效果,我们就不能再使用之前学习的光照模型。由于变成一块纯色区域了,因此光照模型显然不合适,我们可以通过阈值比较来判断,小于阈值则反光系数为0,否则为1。
float spec = dot(worldNormal,worldHalfDir);
spec = step(threshold,spec);
step函数用于比较,第一个参数为参考值,第二个参数为待比较的值,若第二个参数大于等于第一个参数则返回1,否则返回0(这个step可以代替if语句判断,只需作为乘积相乘比较结果即可)
spec = lerp(0,1,smoothstep(-w,w,spec - threshold));
这个函数smoothstep,作用是当spec - threshold小于第一个参考值返回0,大于第二个参考值返回1,否则在[0,1]之间进行插值。这样就可以保证边缘处的过渡不会突兀,实现边缘抗锯齿。
实现
Shader "Custom/ToonShading_Copy"
{
Properties
{
_Color("Tint Color",Color) = (1,1,1,1)
_MainTex("Map Tex",2D)="white"{}
_RampTex("Ramp Tex",2D) = "grey"{}
_Outline("Outline",Range(0,1)) = 0.1
_OutlineColor("Outline Color",Color) = (1,1,1,1)
_Specular("Specular",Color) = (1,1,1,1)
_SpecularScale("Specular Scale",Float) =1.0
}
SubShader
{
Tags
{
"RenderType"="Opaque" "Queue"="Geometry"
}
CGINCLUDE
#include "UnityCG.cginc"
fixed4 _Color, _OutlineColor, _Specular;
fixed _Outline;
float _SpecularScale;
sampler2D _MainTex, _RampTex;
half4 _MainTex_ST;
ENDCG
// 背面渲染一次并根据法线方向挤出顶点
Pass
{
Name "OUTLINE"
Cull Front
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
struct v2f
{
float4 pos : SV_POSITION;
};
v2f vert(appdata_base v)
{
v2f o;
// 此处直接按照法线方向挤出顶点,描边效果稍微不同
v.vertex.xyz += v.normal * _Outline;
o.pos = UnityObjectToClipPos(v.vertex);
return o;
}
fixed4 frag(v2f i):SV_Target
{
return fixed4(_OutlineColor.rgb, 1);
}
ENDCG
}
// 正面渲染
Pass
{
Tags
{
"LightMode"="ForwardBase"
}
Cull Back
CGPROGRAM
#pragma multi_compile_fwdbase
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
#include "AutoLight.cginc"
struct v2f
{
float4 pos : POSITION;
float2 uv : TEXCOORD0;
float3 worldNormal : TEXCOORD1;
float3 worldPos : TEXCOORD2;
SHADOW_COORDS(3)
};
v2f vert(appdata_full v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex);
TRANSFER_SHADOW(o);
return o;
}
fixed4 frag(v2f i) :SV_Target
{
fixed3 worldNormalDir = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 worldViewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
fixed3 worldHalfDir = normalize(worldLightDir + worldViewDir);
fixed4 c = tex2D(_MainTex, i.uv);
fixed3 albedo = c.rgb * _Color.rgb;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
// 计算与阴影方向,并从标准坐标系[-1,1]映射到[0,1]以采样渐变纹理
fixed diff = dot(worldNormalDir, worldLightDir);
diff = (diff * 0.5 + 0.5) * atten;
fixed3 diffuse = _LightColor0.rgb * albedo * tex2D(_RampTex, float2(diff, diff)).rgb;
// 计算高光方向
fixed spec = dot(worldNormalDir, worldHalfDir);
//fwidth(v) = abs(ddx(v))+ abs(ddy(v)) 以2x2像素为单位,ddx为右边界-左边界,ddy为下边界-上边界
// fwidth的返回值表明UV值在该点和临近像素之间的变化,这个值帮助我们判断模糊的大小范围
// 总之是用于smoothstep对像素进行模糊的
fixed w = fwidth(spec) * 2.0;
// smoothstep柔和过渡避免锯齿
fixed3 specular = _Specular.rgb * lerp(0, 1, smoothstep(-w, w, spec + _SpecularScale - 1)) * step(
0.0001, _SpecularScale);
return fixed4(ambient + diffuse + specular, 1.0);
}
ENDCG
}
}
FallBack "Diffuse"
}
效果:直接根据法线挤出顶点并描边,可以把面部的轮廓线也描绘出来
书中对背面面片渲染的代码如下:
v2f vert (a2v v) {
v2f o;
float4 pos = mul(UNITY_MATRIX_MV, v.vertex);
float3 normal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
normal.z = -0.5;
pos = pos + float4(normalize(normal), 0) * _Outline;
o.pos = mul(UNITY_MATRIX_P, pos);
return o;
}
通过在顶点着色器中将模型顶点转换到view空间下,从而使得描边在观察空间获得最好的效果,并对法线设置z分量后归一化,将顶点沿着法线方向凸出,最终转换到裁剪空间获得描边的效果
相比之下一些正对视角的顶点边缘是没有描绘的,效果见仁见智吧
素描风格的渲染
素描风格的渲染本质上是根据光照结果来进行素描纹理权重混合的渲染。这种渲染风格可能一次需要多种不同的纹理贴图,使用笔触越多的纹理来模拟越深的阴影。
我们将使用六张纹理图来渲染光照以实现上面这种效果
ToonStyle的渲染在现在二次元手游这么多的情况下显得越来越重要了。