NPR&卡通渲染

术语介绍:

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进行任意角度的旋转。

这个结合运行的例子可以观察其效果:
在这里插入图片描述
在这里插入图片描述

当改变_SpecularRotationX 的时候:
在这里插入图片描述

在这里插入图片描述

可以看到白斑进行一定的位置偏移,这个是经过对半角向量H进行旋转的结果。后面还有一个专门进行平移的操作。

// specular highlights translation
H = H + float3(_SpecularTranslationX, _SpecularTranslationY, 0);
H = normalize(H);

在这里插入图片描述
在这里插入图片描述

改变x的平移:
在这里插入图片描述

在这里插入图片描述

所以这个平移也能改变白斑的位置。

接着对变换之后的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

  • 3
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值