作者:王子饼干 ——镇江虹视游戏科技有限公司联合创始人、《多洛可小镇》制作人
原文链接:UnityShader入门精要笔记18:非真实感渲染
原文发布时间:2020年3月30日
文章类型: 授权转载
人生不是一种享乐,而是一桩十分沉重的工作。 ——列夫·托尔斯泰
笔记当前使用的Unity版本:“2019.3.3”
笔记当前Unity最新发行版:“2019.3.7”
在开始本章之前,要强调一下,我略过了原书的第12章和第13章,这两章内容已经过时了,虽然其中还有很多有用的东西,这些部分我会在该系列的补充里面说到。如果补充中没有给出,也会在后续的系列中讲到。本节主要是“非真实感渲染”,也就是所谓的NPR(Non-Photorealistic Render),说的通俗一点,也就是卡通渲染,不过只是较早期的卡通渲染,大概讲述一下卡通渲染的原理,后面如果有需要的话,也会做次世代卡通渲染的博客或者学习笔记。
卡通风格渲染是一种常见的渲染风格,常见的作品包括《无主之地》系列,卡普空的《塞尔达传说》,以及国内《疾风之刃》,米哈游的《崩坏3》以及《原神》等作品
这些都可以算得上是次世代的卡通渲染了,这些作品会使用比较多的遮罩控制纹理来非常细节的控制漫反射,高光,环境光等部分。
它们都有一些非常明显的特征,它们的光照区域一般都是纯色,会使用黑色来对外边框进行描边。
用于实现卡通渲染的方式很多,这里我们使用的是基于色调的着色技术(tone-based shading),我们早在使用渐变纹理的时候就提到过这个技术,我们对一张一维的渐变纹理进行采样,用于控制各个部分的光照。这种技术简单但是带来的效果也很直观。
除了光照着色,还需要对物体进行描边,描边类型有后期处理描边,但是我们这里必须是基于模型的描边。
渲染轮廓线说白了就是描边,实时渲染中,渲染轮廓线是应用非常广泛的一种效果。近20年来,有许多绘制模型轮廓线的方法被提出来,据《Real Time Rendering,third edition》一书,大概可以分为5种类型。
首先我们会使用第二种,也就是过程式几何轮廓渲染来实现描边效果。它会绘制两个pass,第一个pass将顶点沿着法线向外扩张一部分的距离,绘制成纯黑色,第二个pass正常的绘制原来的物体,这样,就可以产生描边效果。
有点类似于,我们画一个圆,如果我们想要这个圆有边框的话,我们就在它的后面画一个稍大一点的圆。并且我们可以自己控制后面那个圆的大小,以此来调整边框大小。
实现方法也很简单,我们只要在视角空间下,将原来的顶点沿着法线方向向外延长一点点即可。实现代码大概是下面这样。
viewPos = viewPos + viewNormal * _Outline;
但是如果直接使用顶点进行法线方向的扩张,对于一些内凹的模型,就可能发生正面面片被背面面片遮挡的情况,为了尽可能的防止这种情况的出现,在扩张背面的顶点之前,我们必须先对顶点法线的z分量进行处理,使它们等于一个定值,然后再对法线归一化后,进行扩张。
viewNormal.z = -0.5;
viewNormal = normalize(viewNormal);
viewPos = viewPos + viewNormal * _Outline;
我们之前使用了Binn-Phong模型来计算高光,它的代码如下:
fixed4 specular = pow(saturate(dot(worldNormal,halfDir)),_Gloss)
在卡通渲染中,由于我们需要离散化的处理光照,所以高光是不会有变化的,它要么是0,要么是1,所以我们必须将法线和半角向量的点乘结果和一个阈值进行比较,如果大于阈值,就是1,否则为0。
float specular = dot(worldNormal,halfDir);
specular = step(threshold,specular);
这里我们所采用的是step函数,它接受一个阈值,然后将传入的参数划归到0和1的两个输出。不过这种从0到1的突变会让高光边界产生非常明显的锯齿。为了防止这种情况,我们会在边缘处做一个过渡。
float spec = dot(worldNormal,halfDir);
spec = lerp(0,1,smoothstep(-w,w,spec - threshold));
这里我们使用了smoothstep函数来处理,它接受两个阈值a和b(a < b),如果目标参数小于a,那么为0,如果目标参数大于b,则为1,否则,返回原值,然后我们把原值再丢入一个插值函数中,用该插值函数在0到1之间过度。
这样,我们就可以在[a,b]之间,也就是高光的边界区域,得到一个从0到1的变化,尽管我们可以把w设定为一个很小的值,但是这里还是选用了fwidth函数,它可以计算出领域像素之间的导数值,用于作为阈值。
以下是ToonShading完整的代码,我们先做一些重点的解析。
Shader "UShaderMagicBook/ToonShading1"{
Properties{
_BaseColor("Base Color",Color) = (1.0,1.0,1.0,1.0)
_Ramp("Ramp Texture",2D) = "white"{} //渐变纹理
_OutLine("Outline",Range(0,1)) = 0.1 //控制轮廓线大小
_OutLineColor("OutLine Color",Color) = (1.0,1.0,1.0,1.0)
_SpecularColor("Specular Color",Color) = (1.0,1.0,1.0,1.0) //高光颜色
_SpecularScale("Specular Scale",Range(0,0.1)) = 0.01 //控制高光区域的阈值
}
SubShader{
pass{
NAME "OUTLINE" //给这个pass起一个名字
/*因为描边一直是一个比较常用的效果,所以命名之后,我们后续只需要引用这个名字即可
通过UsePass指令*/
Cull Front //我们只需要渲染三角面的背面,不需要渲染正面,所以把正面剔除掉。
CGPROGRAM
#pragma vertex Vertex
#pragma fragment Pixel
#include "UnityCG.cginc"
fixed _OutLine;
fixed4 _OutLineColor;
float4 Vertex(appdata_base v):SV_POSITION{
float3 _pos = UnityObjectToViewPos(v.vertex.xyz);
float4 pos = float4(_pos,1);
//将顶点转换到视角空间
float3 normal = mul((float3x3)UNITY_MATRIX_IT_MV,v.normal);
//将法线转换到视角空间
normal.z = -0.5;
pos = pos + float4(normalize(normal) * _OutLine,0);
return UnityViewToClipPos(pos);
}
fixed4 Pixel():SV_TARGET{
return float4(_OutLineColor.xyz,1);
}
ENDCG
}
pass{
Tags{"LightMode"="ForwardBase"}
Cull Back
CGPROGRAM
#pragma vertex Vertex
#pragma fragment Pixel
#pragma multi_compile_fwdbase
#include "AutoLight.cginc"
#include "UnityCG.cginc"
#include "Lighting.cginc"
struct vertexOutput{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float3 worldNormal : TEXCOORD1;
float3 worldPos : TEXCOORD2;
SHADOW_COORDS(3)
};
fixed4 _BaseColor;
sampler2D _Ramp;
fixed4 _SpecularScale;
fixed4 _SpecularColor;
vertexOutput Vertex(appdata_base v){
vertexOutput o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld,v.vertex).xyz;
TRANSFER_SHADOW(o);
return o;
}
fixed4 Pixel(vertexOutput i):SV_TARGET{
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 worldViewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
fixed3 worldHalfDir = normalize(worldViewDir + worldLightDir);
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * _BaseColor.xyz;
UNITY_LIGHT_ATTENUATION(atten,i,i.worldPos);
fixed diff = dot(worldNormal,worldLightDir);
diff = (diff * 0.5 + 0.5) * atten;
fixed3 diffuse = _LightColor0.xyz * _BaseColor.xyz * tex2D(_Ramp,float2(diff,diff)).xyz;
fixed specular = dot(worldHalfDir,worldNormal);
fixed w = fwidth(specular) * 2.0;
specular = _SpecularColor.xyz * lerp(0,1,smoothstep(-w,w,specular + _SpecularScale - 1)) * step(0.0001,_SpecularScale);
return fixed4(ambient + diffuse + specular,1.0);
}
ENDCG
}
}
}
这个Shader分为两个pass,第一个pass用于渲染边框,本次的计算都是在视角空间完成的,所以注意我们的顶点和法线都是转换到视角空间,然后由视角空间再转换到裁剪空间。然后我们将顶点稍微往外延伸一点出来,最后使用UnityViewToClipPos把视角空间的坐标转换到裁剪空间。
至于片元着色器,由于线框是纯色,所以直接返回我们设定的边框颜色就行了。
UNITY_LIGHT_ATTENUATION(atten,i,i.worldPos);
//漫反射计算部分
fixed diff = dot(worldNormal,worldLightDir);
diff = (diff * 0.5 + 0.5) * atten;
//这个就是广义兰伯特公式,只不过alpha算子和beta算子都设为了0.5
fixed3 diffuse = _LightColor0.xyz * _BaseColor.xyz * tex2D(_Ramp,float2(diff,diff)).xyz;
至于漫反射计算部分,这里的使用了广义兰伯特公式,Alpha算子和Beta算子都设为了0.5,这样就可以不用max函数来强制diff在0以上了。然后剩下的部分就是在渐变纹理中使用diff作为参数来采样。如果大家忘记了,可以回顾一下之前的渐变纹理一章。
fixed specular = dot(worldHalfDir,worldNormal);
fixed w = fwidth(specular) * 2.0;
specular = _SpecularColor.xyz * lerp(0,1,smoothstep(-w,w,specular + _SpecularScale - 1)) * step(0.0001,_SpecularScale);
高光的计算和之前说的一样,主要是针对边缘区域的平滑过渡。
ok~我们来看一下效果。
大概就是这个感觉,我所使用的渐变纹理如下:
它是有一块小的白色区域的 = =。
这是另外一种不同类型的非真实感渲染,它有点类似于风格转换,比如把一张图改成浮雕风格,或者素描风格,或者水墨风格。这就是非常有名的色调艺术映射(Tonal Art Map,TAM)
我们需要准备目标风格下的纹理图,并且用它们来表达出阴影,和渐变纹理仍然是一个概念。就是把原本的漫反射的值(一个具有大小的值)映射到这些纹理上。值比较大的对应着颜色比较浅的纹理,值比较小的对应颜色比较深的纹理。
当我们计算出diffuse的值是多少的时候
diffuse = saturate(dot(worldNormal,worldLightDir));
需要用这个值来在6张纹理之间选择一个合适的纹理来表达出当前的漫反射亮度。那么我们可以把亮度除以6,因为亮度原本是一个0到1的值,比如亮度在[0,1/6]之间,我们就选择最暗的图。亮度在[1/6,2/6]之间,我们就选择倒数第二张图。
这样处理虽然有点粗糙,但是毕竟没有什么问题,不过我们可以让它更加方便一点,我们把原本在[0,1]之间的diffuse值映射到[0,7]之间(乘以一个7),这样,我们就可以更好的判断了。
为什么是7而不是6呢,这是因为其实是有第七张图片的,只不过它是纯白色的,不用特意的准备一张纹理图,只要用1来表示就可以了。
我们可以用一个多重判断,判断diffuse值是几点几,假设是0.x,我们就可以直接减去0,用0.x来在最后一张图上面采样。假设是1.x,我们可以减去1,用0.x在倒数第二张纹理上采样。同理,3.x,4.x,5.x都是一样。但是我们注意一个问题,我们怎么标记出一个数字是哪一张图片的权重呢?
为了解决这个问题,我们准备了两个fixed3类型的变量,也就依次是
fixed3 weights1;
fixed3 weights2;
weights1.x; //第1张图片
weights1.y; //第2张图片
weights1.z; //第3张图片
weights2.x; //第4张图片
weights2.y; //第5张图片
weights2.z; //第6张图片
另外还有一个问题,两者纹理直接直接拼接会有点生硬,所以当我们选择在两张纹理之间进行过度,如果漫反射值为5.x,那么
_current_weight = diff - 5.0; //获得小数部分
weights1.x = _current_weight;
weights1.y = 1 - _current_weight;
//每个部分由两张相邻的图混合在一起,这张图的权重就是上一张图权重的反相。
ok,该解释的就是这么多啦。我们来看一下完整的代码。
Shader "UShaderMagicBook/TMA"{
Properties{
_BaseColor("Base Color",Color) = (1.0,1.0,1.0,1.0)
_TileFactor("Tile Factor",Float) = 1
_OutLine("OutLine",Range(0,1)) = 0.1
_H1("Hatch 0",2D) = "white"{}
_H2("Hatch 1",2D) = "white"{}
_H3("Hatch 2",2D) = "white"{}
_H4("Hatch 3",2D) = "white"{}
_H5("Hatch 5",2D) = "white"{}
_H6("Hatch 6",2D) = "white"{}
}
SubShader{
pass{
Tags{
"LightMode"="ForwardBase"
}
CGPROGRAM
#pragma vertex Vertex
#pragma fragment Pixel
#pragma multi_compile_fwdbase
#include "Lighting.cginc"
#include "UnityCG.cginc"
#include "AutoLight.cginc"
struct vertexOutput{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
fixed3 hatchWeights0 : TEXCOORD1;
fixed3 hatchWeights1 : TEXCOORD2;
//注意,我们用两个fixed3变量来储存6个值
float3 worldPos : TEXCOORD3;
SHADOW_COORDS(4)
};
fixed4 _BaseColor;
float _TileFactor;
fixed _OutLine;
sampler2D _H1;
sampler2D _H2;
sampler2D _H3;
sampler2D _H4;
sampler2D _H5;
sampler2D _H6;
vertexOutput Vertex(appdata_base v){
vertexOutput o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord.xy * _TileFactor;
fixed3 worldLightDir = normalize(WorldSpaceLightDir(v.vertex));
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
fixed diff = saturate(dot(worldLightDir,worldNormal));
o.hatchWeights0 = fixed3(0,0,0);
o.hatchWeights1 = fixed3(0,0,0);
float hatchFactor = diff * 7.0;
//将diff由[0,1]映射到[0,7],为了它可以方便的选择目标图片
if(hatchFactor > 6.0){
//纯白色 do nothing;
//不需要一张图,所以这个权重可以直接用1 - 所有的权重获得。
}else if(hatchFactor > 5.0){
o.hatchWeights0.x = hatchFactor - 5.0;
//如果diff的值是5.x,那么我们就用第一张图片与权重0.x来表达出5.x的亮度
}else if(hatchFactor > 4.0){
o.hatchWeights0.x = hatchFactor - 4.0;
o.hatchWeights0.y = 1 - o.hatchWeights0.x;
//同上,只不过这里我们需要和一张相邻的图混合在一起。
}else if (hatchFactor > 3.0){
o.hatchWeights0.y = hatchFactor - 3.0;
o.hatchWeights0.z = 1 - o.hatchWeights0.y;
}else if(hatchFactor > 2.0){
o.hatchWeights0.z = hatchFactor - 2.0;
o.hatchWeights1.x = 1 - o.hatchWeights0.z;
}else if(hatchFactor > 1.0){
o.hatchWeights1.x = hatchFactor - 1.0;
o.hatchWeights1.y = 1 - o.hatchWeights1.x;
}else{
o.hatchWeights1.y = hatchFactor;
o.hatchWeights1.z = 1 - hatchFactor;
}
o.worldPos = mul(unity_ObjectToWorld,v.vertex).xyz;
TRANSFER_SHADOW(o)
return o;
}
fixed4 Pixel(vertexOutput i):SV_TARGET{
fixed4 hatchTex0 = tex2D(_H1,i.uv) * i.hatchWeights0.x;
fixed4 hatchTex1 = tex2D(_H2,i.uv) * i.hatchWeights0.y;
fixed4 hatchTex2 = tex2D(_H3,i.uv) * i.hatchWeights0.z;
fixed4 hatchTex3 = tex2D(_H4,i.uv) * i.hatchWeights1.x;
fixed4 hatchTex4 = tex2D(_H5,i.uv) * i.hatchWeights1.y;
fixed4 hatchTex5 = tex2D(_H6,i.uv) * i.hatchWeights1.z;
fixed4 whiteColor = fixed4(1,1,1,1) * (1 - i.hatchWeights0.x - i.hatchWeights0.y - i.hatchWeights0.z - i.hatchWeights1.x - i.hatchWeights1.y - i.hatchWeights1.z);
fixed4 hatchColor = hatchTex0 + hatchTex1 + hatchTex2 + hatchTex3 + hatchTex4 + hatchTex5 + whiteColor;
UNITY_LIGHT_ATTENUATION(atten,i,i.worldPos);
return fixed4(hatchColor.xyz * _BaseColor.xyz * atten,1.0);
}
ENDCG
}
}
}
然后它的最终效果如下
这个纹理素材是我自己画的,因为我实在是找不到素描纹理。它可能效果不是很好,缝合处也有问题~(狗头),答应我,素材一定要找好的,好吗~
ok,本节就是这些内容啦,卡通渲染是一个比较大的主题,原书只是给出了一种最基础的实现方式,这个系列的文章会在后续的系列中继续探讨卡通渲染的。欢迎大家关注~
另外,本次使用到的所有代码都已经上传到了我的github,在下面的链接中可以找到。如果你喜欢这个系列的文章,欢迎给我的github贡献一颗⭐,感谢阅读。