术语介绍:
NPR——non photorealistic render. 即非真实感图形学。
Cel-Shading 或者叫做 ToneBasedShading 即所谓的卡通渲染。
学习曲线:
https://blog.csdn.net/jvandc/article/details/81171250#npr卡通渲染
https://blog.csdn.net/candycat1992/article/details/37882425#t3
https://www.cnblogs.com/zhanlang96/p/4241727.html
https://assetstore.unity.com/packages/vfx/shaders/toony-colors-free-3926
https://github.com/candycat1992/NPR_Lab git hub
https://roystan.net/articles/toon-shader.html
https://github.com/candycat1992/NPR_Lab/tree/master/Assets git hub npr shader effect
轮廓线,勾边,描边
图像的边缘可以指灰度不连续,或者亮度、深度、表面法线、表面反射系数等图像像素“值不连续的地方。
可以使用图像灰度或是图像亮度检测图像边缘可根据需要选择。
边缘检测的方法:
Sobel 算子 Canny 算子
首先介绍sobel算子
目标是能投检测出一个图片的边缘,然后用代码实现。关于这个任务的学习参考:
https://blog.csdn.net/wodownload2/article/details/89515722
canny算子等待完成。
关于轮廓线的有好几种实现方式,这里分别给出实现原理和实现代码。
几何描边实现方式,参考:https://blog.csdn.net/wodownload2/article/details/89552257
npr effect 代码分析。
可以参考的代码是:https://github.com/candycat1992/NPR_Lab/tree/master/Assets
后者是从网上下载的资源附带的shader:NPR Cartoon Effect v2.5.unitypackage
这里分析后者是怎么实现的。
首先分析其有两个通道:
其中第二个通道是描边,原理采用的是我们之前的几何描边方法:https://blog.csdn.net/wodownload2/article/details/89552257
在此忽略不看。
我们看下第一个通道的顶点着色器是怎么写的。
这里由应用阶段传入到顶点着色器的数据采用的是appdata_tan,其原型在UnityCG.cginc中:
在顶点着色器中需要注意的是:
对两个纹理进行的uv的缩放以及偏移。一个是主纹理,一个是风格阴影纹理。
TANGENT_SPACE_ROTATION,宏是在UnityCG.cginc中,其原型如下:
// Declares 3x3 matrix 'rotation', filled with tangent space basis
#define TANGENT_SPACE_ROTATION \
float3 binormal = cross( normalize(v.normal), normalize(v.tangent.xyz) ) * v.tangent.w; \
float3x3 rotation = float3x3( v.tangent.xyz, binormal, v.normal )
之前的学习中 ,我们知道TBN矩阵的构建方法是,t——切线;b——副法线;n——法线
其中模型的数据中,只要保留法线和切线,b则由n叉乘t得到。注意unity里面的使用的是左手坐标系,所以解释也要用左手叉乘,为了保证坐标系的正确,所以在最后乘以了一个v.tangent.w。
最后,由float3x3得到一个tbn矩阵,也就是切线空间矩阵了。
o.tgsnor = mul(rotation, v.normal);
用tbn矩阵,去变换顶点的法线,则是将模型空间的法线,转换到tbn空间了。
同理:
o.tgslit = mul(rotation, ObjSpaceLightDir(v.vertex));
o.tgsview = mul(rotation, ObjSpaceViewDir(v.vertex));
上面的一句则是利用了ObjSpaceLightDir函数,将传入的模型空间的顶点,计算出模型空间的点到光源的方向。这个函数也在UnityCG.cgin中,如下:
// Computes object space light direction
inline float3 ObjSpaceLightDir( in float4 v )
{
float3 objSpaceLightPos = mul(unity_WorldToObject, _WorldSpaceLightPos0).xyz;
#ifndef USING_LIGHT_MULTI_COMPILE
return objSpaceLightPos.xyz - v.xyz * _WorldSpaceLightPos0.w;
#else
#ifndef USING_DIRECTIONAL_LIGHT
return objSpaceLightPos.xyz - v.xyz;
#else
return objSpaceLightPos.xyz;
#endif
#endif
}
首先传入的参数是模型空间的顶点坐标。
然后利用unity_WorldToObject矩阵,将世界空间的光源位置转换到模型空间。
如果没有宏USING_LIGHT_MULTI_COMPILE,则可以从_WorldSpaceLightPos0的w,来判断是平行光还是其他类型的光源。
如果是平行光,w为0,所以v.xyz * _WorldSpaceLightPos0.w = 0,所以返回的就是平向光的方向。
如果不是平行光,w为1,所以用objSpaceLightPos.xyz - v.xyz * 1,得到的是顶点指向光源的方向。
else里面,则是考虑如果定义了USING_LIGHT_MULTI_COMPILE,则按照上面的规则同样计算。
即,如果没有定义:USING_DIRECTIONAL_LIGHT
则用两个位置相减;否则就是平行光。
ok,到这里我们知道了,其实就是计算模型空间的点到光源的方向。
所以:
o.tgslit = mul(rotation, ObjSpaceLightDir(v.vertex));
则是得到了切线空间中的顶点到光源的向量。
o.tgsview = mul(rotation, ObjSpaceViewDir(v.vertex));
则是得到了切线空间中的顶点到摄像机的向量。
这里再看看ObjSpaceViewDir函数,也在UnityCG.cginc中:
// Computes object space view direction
inline float3 ObjSpaceViewDir( in float4 v )
{
float3 objSpaceCameraPos = mul(unity_WorldToObject, float4(_WorldSpaceCameraPos.xyz, 1)).xyz;
return objSpaceCameraPos - v.xyz;
}
ok。
接着,
TRANSFER_VERTEX_TO_FRAGMENT(o);
这个宏在AutoLight.cginc中,具体如下:
#define TRANSFER_VERTEX_TO_FRAGMENT(a) COMPUTE_LIGHT_COORDS(a) TRANSFER_SHADOW(a)
我们看第一个:
COMPUTE_LIGHT_COORDS(a)
它就是计算了点在光源空间下的位置。具体可参看AutoLight.cginc中的关于点光源、聚光灯、平行光对应的计算方法。
TRANSFER_SHADOW(a)
它是计算阴影的采样纹理坐标。
解释参考:https://blog.csdn.net/NotMz/article/details/82053659
还有我之前未翻译完成的:https://blog.csdn.net/wodownload2/article/details/82150390
下面就是进入片段着色器的阶段:
如果定义了宏NCE_BUMP,那么法线从法线贴图中采样得到;否则使用切线空间中的法线。
然后是对光源方向,眼睛方向进行归一化,最后求出V和L的半角向量,然后再归一化。
接着,计算的是环境光的颜色:
float3 ambientColor = UNITY_LIGHTMODEL_AMBIENT.xyz;
然后结算的是反射率:
half rim = 1.0 - saturate(dot(V, N));
rim = smoothstep(_RimMin, _RimMax, rim) * _RimColor.a;
fixed4 albedo = tex2D(_MainTex, i.tex.xy);
albedo = lerp(albedo, _RimColor, rim);
这里首先计算了,边缘光因子,用的是V和N点乘,然后1-dot值,取反。前面我们讲过rim实现的原理,参考:
https://blog.csdn.net/wodownload2/article/details/89553427
有了这个rim值,之后,然后再从主纹理中采样一个反射率,最后在主纹理颜色和_RimColor颜色之间,使用rim因子进行lerp。
lerp的算法:(1-rim)albedo + t_RimColor;
当rim为0,表示在正对着人眼的方向,所以此时处在非边缘位置。
当rim为1,表示在边缘位置,所以全部取的是_RimColor。
这个代码的意思是:在边缘光颜色和漫反射贴图的颜色之间取一个lerp。这里有一个函数我们是很陌生的,就是smoothstep函数。
此时可以参考:
https://blog.csdn.net/libing_zeng/article/details/68924521 给出
https://docs.microsoft.com/zh-cn/previous-versions/hh308343(v=vs.120) msdn的简洁
https://thebookofshaders.com/glossary/?search=smoothstep 有公式推导
总结:smoothstep smoothstep(min, max, x) 如果x的范围是[min, max],则返回一个介于0和1之间的Hermite插值。
也就是说上面的代码是在求出rim因子之后,对rim因子在_RimMin, _RimMax之间做平滑过渡,而_RimMin和_RimMax的值为:
_RimMin (“Rim Min”, Float) = 0.5
_RimMax (“Rim Max”, Float) = 1
rim = smoothstep(_RimMin, _RimMax, rim) * _RimColor.a;
这个是对差值出来的值进一步的进行缩放,没有什么好讲的,只是用_RimColor的a通道进行缩放而已,省的在定义一个变量罢了。
fixed4 albedo = tex2D(_MainTex, i.tex.xy);
albedo = lerp(albedo, _RimColor, rim);
lerp的对象,采样的漫反射纹理和_RimColor颜色,lerp的因子,使用的是rim的因子,当rim为1,完全使用的是_RimColor;当rim为0,则使用albedo。
我们可以控制下_RimColor.a通道试试。
其实这里我们可以思考下,作者使用这种方式混合的目的是什么,之前我们的边缘光是:https://blog.csdn.net/wodownload2/article/details/89553427
采用的是加一个自发光的形式:
但是这样的一个不好的地方是,如果col+=emissive溢出了,那么则是全白色的了。
所以这个方法不太好,那上面的方法的好处是,保证了漫反射颜色和边缘光颜色之间做个平滑的过渡,而这个过渡使用的是smoothstep函数求出lerp因子,然后对因子进行缩放,然后再在漫反射颜色和边缘光颜色之间做lerp。
调整参数可以有这样的效果,这个读者可以自己去改下,将frag返回的颜色只返回漫反射颜色观察每个颜色计算的结果。
至此,还没有完,源代码,继续计算了,漫反射因子,然后对这个因子进行卡通模型的处理。然后最终用这个颜色和上面得到的颜色进行乘法融合。
漫反射因子的计算很常规,这里还考虑的衰减因子,使用了宏LIGHT_ATTENUATION,它在AutoLight.cginc中:
float diff = saturate(dot(N, L)) * LIGHT_ATTENUATION(i);
接下就要看看,卡通渲染中,如何对这个diff值进行修改的,首先是不修改的时候的效果:
其效果为:
效果为:
后面会看到这个是使用smoothstep函数进行离散化的结果,还是比较平滑的,去除了最暗和最亮的颜色,参考smoothstep部分知识。
可以看到,明显后者更加明暗分明了,这也是卡通渲染的目的,就是让明暗分界清除。所以最关键的是看看他是如何把这个因子进行二值化的?
函数如下:
fixed calcRamp (float ndl)
{
#if NCE_RAMP_TEXTURE
fixed ramp = tex2D(_RampTex, float2(ndl, 0.5)).r;
#else
fixed ramp = smoothstep(_RampThreshold - _RampSmooth * 0.5, _RampThreshold + _RampSmooth * 0.5, ndl);
#endif
return ramp;
}
一种方法是使用纹理,这个纹理是RampTexure,其样子如下:
放大之后可以看的更仔细:
它只有三种颜色,分别是黑、灰、白。也就是说,我们把漫反射因子由连续的值,映射到离散的值上了,这个离散的值只包含3个颜色。
如下图所示:
这样的效果就很奇怪,因为只有三种diffuseFactor了,其结果如下:
上图解释:
首先根据公式计算出dot(n,l)的值,乘以衰减因子;这个值是连续的,为什么呢?因为顶点的法线和光源方向点乘处理的值是连续的。而x轴为diffuseFactor,y轴也定义为diffuseFactor,所以是一条y=x的直线;而下图呢?我们把dffiuseFactor离散化之后,分为了三段阶梯形直线,这就是离散化之后的diffuseFactor。
另外一种是使用smoothstep函数:
fixed ramp = smoothstep(_RampThreshold - _RampSmooth * 0.5, _RampThreshold + _RampSmooth * 0.5, ndl);
首先区间如下:
所以这个平滑函数也是使用差值的方式,但是看起很连续,其结果如下:
其实我们可以看看这个网址:https://thebookofshaders.com/glossary/?search=smoothstep
给出的smoothstep对应的图片:
这个图片的颜色就很连续了,渐变的颜色很连续,不像上面给出的只有三个颜色的ramp texture。所以我们可以考虑直接改ramp texture,让美术给出一个渐变连续的图片即可;或者使用这个smoothstep函数也是可以的。
ok,漫反射颜色的部分分析完毕,下面就是分析下高光的计算方法了:
fixed3 specularColor = calcSpecular(N, H);
这里的N是从法线贴图上解压出来的法线,或者是模型自带的法线,并且转换之后到切线空间的法线。
#if NCE_BUMP
float3 N = UnpackNormal(tex2D(_BumpTex, i.tex.xy));
#else
float3 N = normalize(i.tgsnor);
#endif
而H是L和V的半角向量:
float3 L = normalize(i.tgslit);
float3 V = normalize(i.tgsview);
float3 H = normalize(V + L);
H也是切线空间的L和V的半角向量。
从这里可以看出它使用的法线计算模型还是常规的blinphone模型。
fixed3 calcSpecular (float3 N, float3 H)
{
#if NCE_STYLIZED_SPECULAR
// specular highlights scale
H = H - _SpecularScaleX * H.x * float3(1, 0, 0);
H = normalize(H);
H = H - _SpecularScaleY * H.y * float3(0, 1, 0);
H = normalize(H);
这个代码啥意思?比如H - _SpecularScaleX * H.x * float3(1, 0, 0);
它是将取出半角向量H的x分量进行缩放,然后变成一个只有x分量的向量,其余轴都是0,最后用H减去这个缩放之后的关于x分量的向量。
这个其实举个例子:
比如原理的半角向量为(1,1,1)。
那么对x分量进行二倍的缩放之后,得到(0,2,0)
然后(1,1,1)-(0,2,0)=(1,-1,1)
这是对半角向量的缩放。
如果改为:
注意到变化是,白斑的长度变长了,第二个发生一定的平移。再来看看y:
这里的白斑,变得更加圆实了,不是那么扁平了,同时白斑之间的距离变大了。
这样对比之后,可以得到出结论,如果想让高光部分进行拉长和拉宽,直接缩放半角向量H即可。
接着:
_SpecularRotationX ("Specular Rotation X", Range(-180, 180)) = 0
_SpecularRotationY ("Specular Rotation Y", Range(-180, 180)) = 0
_SpecularRotationZ ("Specular Rotation Z", Range(-180, 180)) = 0
这个是三个properties中声明的旋转的度数。
float radX = _SpecularRotationX * DegreeToRadian;
float3x3 rotMatX = float3x3(
1, 0, 0,
0, cos(radX), sin(radX),
0, -sin(radX), cos(radX));
float radY = _SpecularRotationY * DegreeToRadian;
float3x3 rotMatY = float3x3(
cos(radY), 0, -sin(radY),
0, 1, 0,
sin(radY), 0, cos(radY));
float radZ = _SpecularRotationZ * DegreeToRadian;
float3x3 rotMatZ = float3x3(
cos(radZ), sin(radZ), 0,
-sin(radZ), cos(radZ), 0,
0, 0, 1);
H = mul(rotMatZ, mul(rotMatY, mul(rotMatX, H)));
H = normalize(H);
这里分别构建了绕x轴、y轴、z轴旋转任意角度的旋转矩阵,对半角向量H进行任意角度的旋转。
可以看到白斑进行一定的位置偏移,这个是经过对半角向量H进行旋转的结果。后面还有一个专门进行平移的操作。
// specular highlights translation
H = H + float3(_SpecularTranslationX, _SpecularTranslationY, 0);
H = normalize(H);
所以这个平移也能改变白斑的位置。
接着对变换之后的H向量进行二值处理:
// specular highlights split
float signX = 1;
if (H.x < 0)
signX = -1;
float signY = 1;
if (H.y < 0)
signY = -1;
如果H的分量x小于0,则符号signX=-1,否则为1;y分量同样处理。
H = H - _SpecularSplitX * signX * float3(1, 0, 0) - _SpecularSplitY * signY * float3(0, 1, 0);
H = normalize(H);
二值处理之后,对于x分量,进行缩放,变为只有x的向量:_SpecularSplitX * signX * float3(1, 0, 0)
y处理类似。
然后用这个H-对应x向量和y向量,得到最后的H向量。
我们回顾下对H向量进行怎样的处理流程:
第一步:进行H分量x和H分量y的缩放;
第二步:进行H向量的旋转;
第三步:进行H向量的平移;
第四部:进行H向量的二值化处理;
第五步:进行H分量x和H分量y的缩放;
接着:
// stylized specular light
float spec = dot(N, H);
float w = fwidth(spec);
return lerp(float3(0, 0, 0), _SpecularColor.rgb, smoothstep(-w, w, spec + _SpecularScale - 1.0));
N和H点积,这是常见的镜面高光因子;
然后fwidth函数,这个函数的使用可以参考:
https://blog.csdn.net/candycat1992/article/details/44673819
不了解:
如果我们将其注释掉,直接使用float w = spec; 看看结果:
看到什么效果了吗?是将高光部分离散化了,更界限分明了。
上面是对使用了卡通风格的高光计算模式,对于普通的计算在else语句里:
#else
float ndh = saturate(dot(N, H));
float spec = pow(ndh, _SpecPower);
spec = smoothstep(0.5 - _SpecSmooth * 0.5, 0.5 + _SpecSmooth * 0.5, spec);
return _SpecularColor * spec;
#endif
}
这个很常规了,就不再解释了。
至此,我们的第一个通道的分析已经完毕,在frag的最后,进行了:
return float4(ambientColor + diffuseColor.rgb + specularColor, 1.0) * _LightColor0;
它还考虑了灯光的颜色,其实每个因子都应该乘以灯光的颜色,所以将其_LightColor0作为公因子提出,否则应该是这样:
ambientColor = ambientColor * _LightColor0
diffuseColor = diffuseColor _LightColor0
specularColor= specularColor* _LightColor0
这个很好理解。
关于第二个通道进行outline的部分,可以再行脑补,这个和之前我讲到的outline技术略微有些不同。
不过这个更好点,因为考虑了,如果摄像机离物体远,则轮廓线更厚,如果离的越近,那么轮廓线越细。
比如:
可以看到如下的轮廓线宽度和物体和相机距离的关系:
#if UNITY_VERSION > 540
o.pos.xy += offset * o.pos.z * _OutlineWidth * dist;
#else
o.pos.xy += offset * o.pos.z * _OutlineWidth / dist;
#endif
也就是如果是unity版本在5.4以后,采用上面一句代码,距离越大,轮廓线越宽;其他低版本,轮廓线和距离成反比。
至此,我们的分析已经全部完成。
完整的项目在:
https://gitee.com/yichichunshui/NPRCartoonEffect.git