【【02】卡通渲染基本光照模型的实现
- 前言
- 1. 轮廓线的实现
- 2.卡通光照模型漫反射部分
- 3. 光照模型物理化重构
- 4. 卡通光照模型物理向高光
- 5. 添加菲涅尔项
- 6. 明暗交界线部分的额外处理
- 7. 抗锯齿
- 总结
本文首发于知乎专栏,转载请注明出处 。
前言
本文作为本专栏
Unity复现《重力眩晕2》中的渲染技术
的第一篇技术实现帖,本文将在unity实现一个基本的卡通渲染的光照模型。本文暂时不使用任何纹理,只是先把光照模型实现一下,也暂时不对金属材质做特殊处理。之后的文章中,只要将光照的系数乘上纹理的采样结果即可。特别的,一些用于遮蔽的纹理本文也暂时不使用。
1. 轮廓线的实现
首先,我们按照《GGXrd SIGN》的outline的思路先实现一个基本的轮廓线。
![](https://img.laitimes.com/img/__Qf2AjLwojIjJCLyojI0JCLiAzNfRHLGZkRGZkRfJ3bs92YsYTMfVmepNHL3tmaOVTWE5keNpHW4Z0MMBjVtJWd0ckW65UbM5WOHJWa5kHT20ESjBjUIF2X0hXZ0xCMx81dvRWYoNHLrdEZwZ1Rh5WNXp1bwNjW1ZUba9VZwlHdssmch1mclRXY39CXldWYtlWPzNXZj9mcw1ycz9WL49zZuBnLzczN2ADO1AjM4AjMxkTMwIzLc52YucWbp5GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.png)
基本思路是,对一个物体做两边遍渲染,用第二遍渲染来实现轮廓线。在第二遍渲染的时候开启正面剔除,在顶点着色器中把顶点沿法线向外延伸一段距离(放大物体)。那么挡在物体前面的部分不会显示,被物体挡住的由于深度剔除也不会显示,就只有轮廓的部分会被保留下来。但这里要注意的是,画轮廓线应该放在第二个Pass进行。如果把两个Pass的顺序调换,当然渲染的结果是一样的,但由于物体部分的像素会被绘制两遍而降低性能。如果把轮廓线放在第二个Pass,可以利用深度测试节省这部分性能。
那么在Unity里放一个球来试一下。我一般喜欢用球来做最开始的着色器调试,因为球的表面均匀分布了所有方向的法线。这里先创建一个unity的Standard的PBR shader给球的材质。然后在subshader的最后加上画轮廓线的Pass。
Pass{
Name "OUTLINE"
Tags{ "LightMode" = "Always" }
Cull Front
ZWrite On
ColorMask RGB
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata {
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texCoord : TEXCOORD0;
};
struct v2f {
float4 pos : SV_POSITION;
float4 color : COLOR;
float4 tex : TEXCOORD0;
};
half _OutlineWidth;
fixed4 _OutlineColor;
v2f vert(appdata v) {
// just make a copy of incoming vertex data but scaled according to normal direction
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
float3 norm = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
float2 extendDir = normalize(TransformViewToProjection(norm.xy));
o.pos.xy += extendDir * (_OutlineWidth * 0.1);
o.tex = v.texCoord;
o.color = _OutlineColor;
return o;
}
half4 frag(v2f i) :COLOR {
return i.color;
}
ENDCG
}//Pass
顶点着色器中将投影到投影空间的顶点坐标向投影空间的法线方向做一段延伸。注意将法线变换到投影空间要乘以transform的转置逆再乘projection矩阵,这样法线不会受到非等比缩放的影响。然后我们看看效果。
基本实现了一个描边的效果,但是在不同深度下仔细观察还是有一些问题。
可以看到,当把摄像头拉进,轮廓线会很粗,摄像头拉远,轮廓线会很细。但是在本专栏的【01】开篇里我们看到,重力眩晕2里的轮廓线是不会随深度而改变的。也就是现在的效果并不是我想要的。我这里解释一下原因及解决办法。
讲原因之前,我们首先来看投影空间:投影空间详解
一个顶点坐标的四个值(x,y,z,w),x,y表示投影空间下的横纵坐标,z表示投影空间下的深度值,w等于z,w用于做归一化。
也就是说,顶点着色器的这行坐标变换得到的x,y的范围是(-w, w)。当在屏幕上做栅格化的时候,管线会将x,y的坐标值除以w就能得到(-1, 1)范围的坐标(ndc)。我们希望得到的是显示在屏幕上的固定宽度的轮廓线,那么顶点向外延伸的距离应该是ndc空间下的固定距离,而不是投影空间下的固定距离。于是我在投影空间下做计算的时候只要将轮廓线宽度乘上w值,再后续的计算中,管线会将坐标值除以w,得到的仍然是人为设定的轮廓线宽度。
将该行代码替换:
然后我们看下效果。
ok,轮廓线的宽度不会随深度改变了,这正是我想要的效果。上两张图轮廓线宽度_OutlineWidth 设的是0.05。
但这样还不够,本专栏【01】开篇中提到,重力眩晕中的轮廓线不是固定宽度的,它通过轮廓线宽度的变化来表现手绘的线条效果。
《重力眩晕2》中这个效果的实现可能利用了纹理来写入线条的宽度或者一些遮蔽信息,但是我暂时没去给自己down的模型画额外的纹理。那我暂时用一个简单方法来实现线条的粗细变化。我这里引入柏林噪声(Perlin Noise),柏林噪声的特点是不会剧烈变化。下面这个柏林噪声的发生函数是我从别的帖子上拿过来的,我找不到出处了,这里暂时就不注明了。
float2 hash22(float2 p) {
p = float2(dot(p, float2(127.1, 311.7)), dot(p, float2(269.5, 183.3)));
return -1.0 + 2.0 * frac(sin(p) * 43758.5453123);
}
float2 hash21(float2 p) {
float h = dot(p, float2(127.1, 311.7));
return -1.0 + 2.0 * frac(sin(h) * 43758.5453123);
}
//perlin
float perlin_noise(float2 p) {
float2 pi = floor(p);
float2 pf = p - pi;
float2 w = pf * pf * (3.0 - 2.0 * pf);
return lerp(lerp(dot(hash22(pi + float2(0.0, 0.0)), pf - float2(0.0, 0.0)),
dot(hash22(pi + float2(1.0, 0.0)), pf - float2(1.0, 0.0)), w.x),
lerp(dot(hash22(pi + float2(0.0, 1.0)), pf - float2(0.0, 1.0)),
dot(hash22(pi + float2(1.0, 1.0)), pf - float2(1.0, 1.0)), w.x), w.y);
}
然后把这个噪声作用到线条宽度上。
这样一来轮廓线的宽度就会有变化,线条不至于显得太死板。 通过调节Perlin Noise的Tilling和offset可以调节轮廓线宽度的变化程度。
轮廓线部分Pass的代码:
Pass{
Name "OUTLINE"
Tags{ "LightMode" = "Always" }
Cull Front
ZWrite On
ColorMask RGB
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata {
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texCoord : TEXCOORD0;
};
struct v2f {
float4 pos : SV_POSITION;
float4 color : COLOR;
float4 tex : TEXCOORD0;
};
uniform half _OutlineWidth;
uniform fixed4 _OutlineColor;
uniform half4 _NoiseTillOffset;
uniform half _NoiseAmp;
v2f vert(appdata v) {
// just make a copy of incoming vertex data but scaled according to normal direction
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
float3 norm = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
float2 extendDir = normalize(TransformViewToProjection(norm.xy));
float2 noiseSampleTex = v.texCoord;
noiseSampleTex = noiseSampleTex * _NoiseTillOffset.xy + _NoiseTillOffset.zw;
float nosieWidth = perlin_noise(noiseSampleTex);
nosieWidth = nosieWidth * 2 - 1; // ndc Space (-1, 1)
half outlineWidth = _OutlineWidth + _OutlineWidth * nosieWidth * _NoiseAmp;
o.pos.xy += extendDir * (o.pos.w * outlineWidth * 0.1);
o.tex = v.texCoord;
o.color = _OutlineColor;
return o;
}
half4 frag(v2f i) :COLOR {
return i.color;
}
ENDCG
}//Pass
套用到其他模型上看一下效果。
左图是加入了柏林噪声的轮廓线。
另外,利用环境遮蔽的纹理来加粗轮廓线的部分,我会在之后的文章中补充实现。
2.卡通光照模型漫反射部分
漫反射亮度采用Lambert模型,再进行阈值化。也就是利用光线和法线的点积作为亮度强弱的判据,然后大于某一个阈值,则显示最亮的颜色,否则判断第二个阈值,以此类推。这里先偷懒用surface shader实现一下。改写surface部分的代码。
#pragma surface surf Toon fullforwardshadows
...
half4 LightingToon(ToonSurfaceOutput s, half3 lightDir, half3 viewDir, half atten) {
half4 c;
half3 nNormal = normalize(s.Normal);
float3 reflectDir = reflect(-viewDir, s.Normal);
half NoL = dot(nNormal, lightDir) + _ShadowAttWeight * (atten - 1);
half3 HDir = normalize(lightDir + viewDir);
half NoH = Pow2(dot(nNormal, HDir)) + _ShadowAttWeight * (atten - 1);
half VoN = dot(nNormal, viewDir);
half VoL = dot(viewDir, lightDir);
half VoH = dot(viewDir, HDir) + _ShadowAttWeight * 2 * (atten - 1);
half Intensity = (NoL > _DividLineH) ? 1.0 : (NoL > _DividLineM) ? 0.8 : (NoL > _DividLineD) ? 0.5 : 0.3;
c = half4(s.diffColor.rgb, s.Alpha) * half4(Intensity.xxx, 1.0) * half4(_LightColor0.rgb, 1.0);
return c;
}
void surf(Input IN, inout ToonSurfaceOutput o)
{
// Albedo comes from a texture tinted by color
fixed4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
o.Albedo = 0.5 * ambient;
o.diffColor = c.rgb * _Color.rgb;
o.Alpha = c.a;
}
这里通过一个阈值判断来实现一个基本的颜色分层效果。
但这样对预期效果来说肯定是远远不够的。第一个问题是,分界线会有很重的锯齿。按照专栏里【01】中的效果,我的明暗分界线应该有一个平滑的过渡,并且在不同的光照角度下,这个过渡区的面积也应该可以变化。这样就可以做出硬边硬和软边影共存的效果。
这里我的想法是,把用if判断亮度级替换为一个平滑阶跃函数(sigmoid)。
我这里凑了一个底数来让sigmoid函数的锐利程度比较好调整。
s ( x ) = s ( x ) = 1 ( 1 + 10000 0 − a x ) . s(x) = s(x) = \frac{1}{(1+100000^{-ax})}. s(x)=s(x)=(1+100000−ax)1.
a参数可以调节sigmoid函数的锐利程度,之后我们把它和粗糙度roughness关联起来。
我们用两个中心不同的sigmoid函数相减就可以造出一个窗函数。
float sigmoid(float x, float center, float sharp) {
float s;
s = 1 / (1 + pow(100000, (-3 * sharp * (x - center))));
return s;
}
然后我利用这个窗函数作为系数乘到亮度级上。
half4 LightingToon(ToonSurfaceOutput s, half3 lightDir, half3 viewDir, half atten) {
half4 c;
half3 nNormal = normalize(s.Normal);
float3 reflectDir = reflect(-viewDir, s.Normal);
half NoL = dot(nNormal, lightDir) + _ShadowAttWeight * (atten - 1);
half3 HDir = normalize(lightDir + viewDir);
half NoH = Pow2(dot(nNormal, HDir)) + _ShadowAttWeight * (atten - 1);
half VoN = dot(nNormal, viewDir);
half VoL = dot(viewDir, lightDir);
half VoH = dot(viewDir, HDir) + _ShadowAttWeight * 2 * (atten - 1);
half HLightSig = sigmoid(NoL, _DividLineH, _BoundSharp);
half MidSig = sigmoid(NoL, _DividLineM, _BoundSharp);
half DarkSig = sigmoid(NoL, _DividLineD, _BoundSharp);
half HLightWin = HLightSig;
half MidLWin = MidSig - HLightSig;
half MidDWin = DarkSig - MidSig;
half DarkWin = 1 - DarkSig;
half Intensity = HLightWin * 1.0 + MidLWin * 0.8 + MidDWin * 0.5 + DarkWin * 0.3;
c = half4(s.diffColor.rgb, s.Alpha) * half4(Intensity.xxx, 1.0) * half4(_LightColor0.rgb, 1.0);
return c;
}
明暗分界线锐利的情况(_BoundSharp值设置较大):
明暗分界线边缘平滑的情况(_BoundSharp值设置较小):
这样一来,我可以把这个分界线锐利度参数和粗糙度联系起来,对光滑的物体,让明暗分界线锐利一些,对粗糙的物体,比如石块,让明暗分界线平滑一些。
第二个好处是,可以实现硬边影和软边影同时存在的艺术效果。
另外,这里解释下,这个2B的模型是我网上down下来的,制作它的美术为了调整光照效果,改变了顶点的法线,我这里为了写基本的光照模型,就没有导入法线,而是用unity重新计算的法线。所以表面某些位置的光照结果可能有问题。
第三个好处是,这里计算了4个窗函数,四个窗函数的和永远等于1,那么漫反射的亮度值不会爆出范围。
3. 光照模型物理化重构
到此为止,当然是远远不够的。一个遵从物理原则的光照模型,应该是满足光照面积越大,亮度越低,光照面积越小,亮度越高。这样才符合能量守恒。所以这里我对每个亮度级的亮度值做一个调整,用该亮度级的上界和下届在lambert下得到的亮度的平均值作为该亮度级的亮度。
float ndc2Normal(float x) {
return x * 0.5 + 0.5;
}
half4 LightingToon(ToonSurfaceOutput s, half3 lightDir, half3 viewDir, half atten) {
half4 c;
half3 nNormal = normalize(s.Normal);
float3 reflectDir = reflect(-viewDir, s.Normal);
half NoL = dot(nNormal, lightDir) + _ShadowAttWeight * (atten - 1);
half3 HDir = normalize(lightDir + viewDir);
half NoH = Pow2(dot(nNormal, HDir)) + _ShadowAttWeight * (atten - 1);
half VoN = dot(nNormal, viewDir);
half VoL = dot(viewDir, lightDir);
half VoH = dot(viewDir, HDir) + _ShadowAttWeight * 2 * (atten - 1);
//------------------------------------------------------------------------------
//diffuse
half MidSig = sigmoid(NoL, _DividLineM, _BoundSharp);
half DarkSig = sigmoid(NoL, _DividLineD, _BoundSharp);
half DeepDarkSig = sigmoid(NoL, _DividLineDeepDark, _BoundSharp);
half MidLWin = MidSig;
half MidDWin = DarkSig - MidSig;
half DarkWin = DeepDarkSig - DarkSig;
half DeepDarkWin = 1 - DeepDarkSig;
half diffuse = MidLWin * (1 + ndc2Normal(_DividLineM)) / 2 + MidDWin * (ndc2Normal(_DividLineM) + ndc2Normal(_DividLineD)) / 2;
diffuse += DarkWin * (ndc2Normal(_DividLineD) + ndc2Normal(_DividLineDeepDark)) / 2 + DeepDarkWin * ndc2Normal(_DividLineDeepDark) / 2;
half Intensity = diffuse;
c = half4(s.diffColor.rgb, s.Alpha) * half4(Intensity.xxx, 1.0) * half4(_LightColor0.rgb, 1.0);
return c;
}
当设置的高亮面积小的情况(高亮判决阈值大):
当设置的高亮面积大的情况(高亮判决阈值小):
最后把明暗交界线的锐利度和roughness关联起来,方便美术向的调参。
这个表达式也是我凑的,因为如果直接用 _BoundSharp = 1 - _Roughness 的话,在_Roughness 的调参范围内效果变化不是很线性,这样会让美术调起来不舒服。
再额外解释下,这里我只是做了一个卡通光照模型的实现,之后可以在每个亮度级上乘上美术想要的冷暖色来实现色相的调整。
4. 卡通光照模型物理向高光
前面卡通化的漫反射的实现思路是,我先用lambert算一个亮度,然后再对这个亮度进行阈值化。我这里实现高光的思路类似。但是对高光,我用更合理的方式来替代lambert。这里我用的是GGX法线分布。
我这里之所以使用GGX的法线分布来作为高光的亮度分级的预计算,有两个原因:
(1)GGX是物理的法线分布函数
(2)GGX是形状不变的法线分布函数。之后实现金属材质的时候,要利用各向异性来表现金属。形状不变的法线分布函数是支持各向异性的。
关于形状不变和各向异性可以参照 @毛星云 大神的博客
这里引一下 @毛星云 大神的图,如果法线分布支持形状不变的话,可以通过给材质沿切线方向和副切线方向不同的粗糙度来实现各向异性。本文暂时用不到各向异性,但是只后要实现金属的卡通渲染,这里提前做个准备。
GGX法线分布函数的大致形状。
// a2 is the roughness^2
float D_GGX(float a2, float NoH) {
float d = (NoH * a2 - NoH) * NoH + 1;
return a2 / (3.14159 * d * d);
}
我这里的想法是,先计算GGX,得到一个物理的亮度,然后对这个亮度做阈值化。当NoH(法线点积半矢量)等于1时就是specular能达到的最大亮度,那我这里以这个亮度的0.85倍作为阈值化的分界线(0.85这个参数可调)。大于0.85的部分为specular的部分,亮度就用最大亮度和NoH=0.85时的亮度的平均值,类似于我第3小节的做法。阈值化仍然使用sigmoid函数。
这样的做法的目的是,我希望粗糙的物体specular面积大,但亮度低;光滑的物体specular面积小,但亮度大。同时,我的高光还是色块化的。
高光部分的计算:
half NDF0 = D_GGX(_Roughness * _Roughness, 1);
half NDF_HBound = NDF0 * _DividLineSpec;
half NDF = D_GGX(_Roughness * _Roughness, clamp(0, 1, NoH));
half specularWin = sigmoid(NDF, NDF_HBound, _BoundSharp);
half specular = specularWin * (NDF0 + NDF_HBound) / 2;
只对高光部分进行渲染的效果:
光滑材质(低roughness):
粗糙材质(中roughness):
高粗糙材质(高roughness):
最后把漫反射和高光反射加在一起:
上两图为不同粗糙度的卡通材质表现,这里我偷懒给右边2B的模型的所有物件套的同一个材质。
5. 添加菲涅尔项
在本专栏【01】中提到,在《重力眩晕2》中,从背光面观察,物体的边缘会有一圈边缘光,实现比轮廓线更好的边界区分效果。
实现这个效果的很容易想到的思路就是利用菲涅尔效应(Fresnel Effect)。
菲涅尔效应表达的效果就是视线和物体法线夹角接近90度时,菲涅尔效应强烈,反之菲涅尔效应弱。一般用Schlick近似:
f r e s n e l ( x ) = r f 0 + ( 1 − r f 0 ) ( ( 1 − x ) 5 ) fresnel(x) = rf_{0} + (1-rf_{0} )((1-x)^{5}) fresnel(x)=rf0+(1−rf0)((1−x)5)
式中x为视线与法线的点积。
float3 Fresnel_schlick(float VoN, float3 rF0) {
return rF0 + (1 - rF0) * Pow5(1 - VoN);
}
float3 Fresnel_extend(float VoN, float3 rF0) {
return rF0 + (1 - rF0) * Pow3(1 - VoN);
}
上述代码是菲涅尔的schlick近似的实现,第二个函数是,我这里希望菲涅尔的衰减不要过快,于是把指数降到3。
无菲涅尔项时,背光面观察效果:
加入菲涅尔项后,背光面观察效果:
另外,由于在向光面观察时不希望收到菲涅尔效应的影响,于是在菲涅尔项的计算中加入VoL(视线与光线的点积)系数的影响因子。
half3 fresnel = Fresnel_extend(VoN, float3(0.1, 0.1, 0.1));
half3 fresnelResult = _FresnelEff * fresnel * (1 - VoL) / 2;
...
half3 Intensity = specular.xxx + diffuse.xxx + fresnelResult.xxx;
c = half4(s.diffColor.rgb, s.Alpha) * half4(Intensity, 1.0) * half4(_LightColor0.rgb, 1.0);
return c;
向光面的观察结果:
向光面的光照将不受到菲涅尔项的影响。
近距离的边缘光效果。
6. 明暗交界线部分的额外处理
在一些插画作品中,会在明暗交界线上加一道暖色调的光来达到一定的艺术效果。
这里我截一张ASK太太的图的一部分:
(CSDN会给图上个水印,但文中我标明了是ASK太太的图)
同理的,插画师们也会在一些冷色材质的明暗交界线上加入冷色光。
为了实现一个近似的效果,我的想法是,用我们之前造出的两个窗函数的乘积来实现。
最后用乘积得到的函数乘上一个暖色光的颜色加到光照函数的输出上。
7. 抗锯齿
轮廓线的抗锯齿问题我会在后续的文章中专门开一期,这里先简单提一下。
我这里在后效上用了一个FXAA的抗锯齿,具体是在OnRenderImage()里用一个着色器来实现。
使用了FXAA:
没有使用FXAA
总结
本期实现了一个基本的卡通渲染的光照模型,暂时还没有挂上任何纹理。实现的几个关键点:基于正面剔除的轮廓线,柔化的明暗边缘,基于菲涅尔效应的边缘光。
下一期我会换一个模型,这期用的模型本身也不是为卡通渲染制作的,存在不少不方便解决的问题。之后把纹理用上,另外制作一张遮蔽纹理来解决对特殊部分的轮廓线加粗的问题。