【Shader】解读Unity Chan的卡通材质

目录

写在前面

分析Unity Chan所用的Shader,并加些注释
本文借鉴的是大佬的《【Unity Shader】Unity Chan的卡通材质》
地址:https://blog.csdn.net/candycat1992/article/details/51050591
《使用CgInclude让你的Shader模块化——使用#define指令创建Shader》
地址:https://blog.csdn.net/candycat1992/article/details/38961411
还有另一位大佬的详细注释《【卡通渲染】 解讀Unity Chan》
地址:https://www.twblogs.net/a/5c0a6dbfbd9eee6fb21348f2

Unity Chan使用的Shader

Unity Chan包含了3个Shader(CG)文件:

名字 用途
CharaOutline 包含了最通用的shader,即绘制描边效果。
CharaMain 角色使用的最主要的Shader,包含了一些漫反射、阴影、高光、边缘高光、反射的通用的vertex shader和fragma shader的实现。用于渲染衣服和头发。
CharaSkin 皮肤使用的shader,包含了漫反射、边缘高光和阴影的实现(相较于CharaMain,没有计算高光和反射)。用于渲染皮肤、眼镜、脸颊、睫毛。

写给自己

UnityChan用了很多的shader,比如有衣服,皮肤的shader,但是打开之后法线跟平时写的不一样,代码量怎么会这么少,后面发现其实代码都写在一些主要的shader里,里面定义了很多自己写的宏,也就是模块化的代码,之后只要在相应的shader里调用相应的模块definition就行
【Shader】解读Unity Chan的卡通材质

CharaOutline:描边

卡通效果需要高光、漫反射等,还要描边,Unity Chan描边的实现也是把顶点沿着法线方向扩张后得到的。

上面的实现,就是把顶点和法线变换到裁剪空间后,把顶点沿着法线方向进行扩张。法线的z分量增加了0.0001,为了稍微防止一下描边挡住正常渲染。不过这个方法有弊端:当描边宽度很大时,会有穿帮镜头。

思路:

  • Vert
    • 转化法线和顶点;
    • 扩大法线,并进行缩放;
      SN = Edge * DIVISOR * n (描边宽度* 轮廓厚度乘数* 法线)
    • 法线的z分量稍稍增加一下,然后和顶点相加
  • Frag
    • 选出最大通道
    • 其他不符合的通道颜色加深
      • 加深通道
        newMapColor = lerp (SATURATION_FACTOR * diff,diff ,lerpVals)
        (饱和系数 * 采样贴图,采样贴图,最大通道为1其他通道为0)
    • 混合
      • float4 ( 亮度系数 * newMapColor * diff , diff.a) * 轮廓颜色定义 * 灯光色

CharaOutline的代码如下

// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'

// Outline shader

// Material parameters
float4 _Color;
float4 _LightColor0;
float _EdgeThickness = 1.0;  //描边宽度
float4 _MainTex_ST;

// Textures
sampler2D _MainTex;

// Structure from vertex shader to fragment shader
struct v2f
{
	float4 pos : SV_POSITION;
	float2 uv : TEXCOORD0;
};

// Float types
#define float_t  half
#define float2_t half2
#define float3_t half3
#define float4_t half4

//definition定义,创建自己的CgInlude去保存光照模型、变量和辅助函数,可以使得代码更加模块化
//告诉Unity去查找这个名称的定义
//Outline thickness multiplier 轮廓厚度乘数
#define INV_EDGE_THICKNESS_DIVISOR 0.00285
// Outline color parameters 轮廓的颜色参数
#define SATURATION_FACTOR 0.6
#define BRIGHTNESS_FACTOR 0.8

// Vertex shader
v2f vert( appdata_base v )  //包含顶点位置,法线和一个纹理坐标。
{
	v2f o;
	o.uv = TRANSFORM_TEX( v.texcoord.xy, _MainTex );
	//将顶点和法线变换到裁剪空间,然后把顶点沿着法线方向进行扩张
	half4 projSpacePos = UnityObjectToClipPos( v.vertex );
	half4 projSpaceNormal = normalize( UnityObjectToClipPos( half4( v.normal, 0 ) ) );

	half4 scaledNormal = _EdgeThickness * INV_EDGE_THICKNESS_DIVISOR * projSpaceNormal; // * projSpacePos.w;

	scaledNormal.z += 0.00001;
	o.pos = projSpacePos + scaledNormal;

	return o;
}

// Fragment shader
float4 frag( v2f i ) : SV_Target
{
	//使得描边的颜色暗于正常渲染的颜色,起到强调边缘的结果
	//亮度系数BRIGHTNESS_FACTOR,用于控制整体变暗的程度
	float4_t diffuseMapColor = tex2D( _MainTex, i.uv );

	//最大通道,比较原贴图的三大通道,取值最大的
	float_t maxChan = max( max( diffuseMapColor.r, diffuseMapColor.g ), diffuseMapColor.b );
	float4_t newMapColor = diffuseMapColor;

	//值最高的分量颜色保持不变,因为此时lerpVals=1,
	//而其他分量只要比最高值小,lerpVals就会取0,需要乘以变暗系数SATURATION_FACTOR 
	maxChan -= ( 1.0 / 255.0 );  //最大通道减1
	float3_t lerpVals = saturate( ( newMapColor.rgb - float3( maxChan, maxChan, maxChan ) ) * 255.0 );
	newMapColor.rgb = lerp( SATURATION_FACTOR * newMapColor.rgb, newMapColor.rgb, lerpVals ); //插值

	return float4( BRIGHTNESS_FACTOR * newMapColor.rgb * diffuseMapColor.rgb, diffuseMapColor.a ) * _Color * _LightColor0;
}

CharaMain:衣服和头发

CharaMain主要用于渲染角色的衣服和头发,使用了这个CharaMain的shader有:Unitychan_chara_hair,Unitychan_chara_hair_ds…后面跟有_ds的时表示是不时双面渲染:没有_ds的在渲染时剔除了背面(Cull Back),而有_ds的则关闭了剔除(Cull off)。
CharaMain 里面具体包括了一堆vert和frag的实现:

  • vert:顶点变换。计算主纹理(_MainTex)的采样坐标,计算世界空间下的法线方向、视角方向、光照方向等
  • frag:主要完成5个工作:
    【Shader】解读Unity Chan的卡通材质

光照衰减

通常计算漫反射是通过对贴图采样后再乘以漫反射系数(n点乘l),不过在这里用的却是法线和观察方向(n点乘v),采样一张衰减贴图,得出衰减值,然后将衰减值去混合原贴图颜色和带阴影的原贴图颜色。不过这样的效果没有考虑到光照方向,而是使用了类似边缘高光的方法计算光照衰减。

	//衰减的光照颜色
	// Falloff. Convert the angle between the normal and the camera direction into a lookup for the gradient

	//【漫反射系数】n*v(实际上应该是法线和光照方向,这里改了)
	float_t normalDotEye = dot( normalVec, i.eyeDir.xyz );

	//【截取漫反射系数】
	//float The float result between the min and max values,将数返回在max和min之间
	//Mathf.abs 取反
	float_t falloffU = clamp( 1.0 - abs( normalDotEye ), 0.02, 0.98 );

	//【衰减纹理采样】用上面的漫反射系数去采样一张衰减纹理
	//FALLOFF_POWER 衰减程度
	float4_t falloffSamplerColor = FALLOFF_POWER * tex2D( _FalloffSampler, float2( falloffU, 0.25f ) );

	//【阴影颜色】平方加深原贴图颜色,作为阴影
	float3_t shadowColor = diffSamplerColor.rgb * diffSamplerColor.rgb;

	//【c=混合了阴影的原贴图】用采样后的衰减纹理R通道 插值 原贴图和阴影颜色
	float3_t combinedColor = lerp( diffSamplerColor.rgb, shadowColor, falloffSamplerColor.r );

	//【c=有阴影、衰减度的原贴图】带阴影的原贴图 * (1+带透明度的衰减纹理)
	combinedColor *= ( 1.0 + falloffSamplerColor.rgb * falloffSamplerColor.a );

高光反射

利用法线与观察方向的点乘结果,n点乘v来获得高光反射系数,然后与漫反射系数以及高光反射的指数部分传给CG的lit函数,计算各个光照系数(返回一个四元向量)。也可以自己写代码计算高光反射光照,这么写应该是为了利用GPU,提高一些性能。

	// Specular高光反射
	// Use the eye vector as the light vector

#ifdef ENABLE_SPECULAR
	//【采样高光反射贴图】
	float4_t reflectionMaskColor = tex2D( _SpecularReflectionSampler, i.uv.xy );

	//【高光反射系数】这里用了n点乘v,实际上是n点乘h
	float_t specularDot = dot( normalVec, i.eyeDir.xyz );

	//【计算各个光照系数】将高光反射系数,还有上面的漫反射系数,还有高光强度代入lit函数里,得到高光反射光照
	//返回一个(光照)4元向量(环境, 漫反射 , 高光 ,1)
	float4_t lighting = lit( normalDotEye, specularDot, _SpecularPower );

	//【高光反射颜色】光照系数的z分量与原贴图颜色、高光反射贴图颜色混合
	float3_t specularColor = saturate( lighting.z ) * reflectionMaskColor.rgb * diffSamplerColor.rgb;

	//【c= 带阴影、衰减度、高光的原贴图】
	combinedColor += specularColor;
#endif

反射

没有使用环境贴图,而是使用了一张普通的二维纹理;采样坐标是通过把反射方向从[-1, 1]映射到[0, 1]来实现的,得到初始的反射颜色;调用GetOverlayColor函数来计算原光照结果和反射颜色混合后的结果;使用反射遮罩值来混合之前的计算结果和反射结果,并和颜色属性以及光源颜色相乘得到结果。
最后还计算了该像素的透明度,也就是漫反射贴图、颜色属性和光源颜色的透明度的乘积,它会作为输出像素的透明通道值。

	//反射
	// Reflection

#ifdef ENABLE_REFLECTION
	//【反射方向】reflect(-v·n),输出的是xzy
	float3_t reflectVector = reflect( -i.eyeDir.xyz, normalVec ).xzy;

	//【坐标采样映射】坐标的xy加1并乘以0.5,目的是把反射方向从【-1,1】映射到【0,1】,得到初始的反射颜色
	float2_t sphereMapCoords = 0.5 * ( float2_t( 1.0, 1.0 ) + reflectVector.xy );

	//【二维纹理采样】
	float3_t reflectColor = tex2D( _EnvMapSampler, sphereMapCoords ).rgb;

	//【混合颜色】GetOverlayColor()函数
	reflectColor = GetOverlayColor( reflectColor, combinedColor );

	//【c=带阴影、衰减度、高光、反射的原贴图】 反射遮罩的透明通道 插值 c和反射颜色
	combinedColor = lerp( combinedColor, reflectColor, reflectionMaskColor.a );
#endif

	combinedColor *= _Color.rgb * _LightColor0.rgb;
	float opacity = diffSamplerColor.a * _Color.a * _LightColor0.a;

阴影

没有使用LIGHT_ATTENUATION和之前的结果计算,而是使用了阴影衰减值插值阴影颜色(漫反射纹理采样结果的平方)和现有颜色,得到的阴影效果就是在阴影覆盖的地方就是变暗了的纹理颜色。(就是把纹理变暗当阴影处理了)

//阴影
#ifdef ENABLE_CAST_SHADOWS //如果开启了投影
	// Cast shadows

	//【阴影颜色】自定义的阴影颜色属性 * c(c包含了阴影、衰减、高光、反射原贴图)
	shadowColor = _ShadowColor.rgb * combinedColor;

	//【阴影衰减值】映射到【-1,1】之间,然后只取【0,1】
	float_t attenuation = saturate( 2.0 * LIGHT_ATTENUATION( i ) - 1.0 );

	//【c】用阴影衰减值 插值 阴影颜色和c 
	combinedColor = lerp( shadowColor, combinedColor, attenuation );
#endif

边缘高光

边缘高光是卡通效果的必备效果,这里也使用了n·l来和n·v相乘,计算边缘高光的衰减,然后用它对一张边缘高光纹理采样,得到真正的边缘高光衰减值。

	//边缘高光
	// Rimlight

#ifdef ENABLE_RIMLIGHT
	//【边缘高光方向】
	float_t rimlightDot = saturate( 0.5 * ( dot( normalVec, i.lightDir ) + 1.0 ) );
	//【边缘高光大小】方向相乘,为采样作为x轴使用
	falloffU = saturate( rimlightDot * falloffU );
	//在边缘高光贴图上采样
	falloffU = tex2D( _RimLightSampler, float2( falloffU, 0.25f ) ).r;
	//与原贴图颜色相乘,之后与c相加
	float3_t lightColor = diffSamplerColor.rgb; // * 2.0;
	combinedColor += falloffU * lightColor;
#endif

大大总结的一些trick:

  • 计算了一个全局的shadowColor,它其实就是漫反射纹理采样结果的平方,效果就是比原贴图颜色暗了一点。
  • 漫反射计算不需要考虑光照方向,而是使用n和v的点乘来计算衰减,这个衰减将会混合上面的shadowColor和正常的颜色贴图。得到的效果是模型边缘部分会较暗
  • 高光反射的部分同样不考虑光照方向,而是使用n和v的点乘。得到的效果是正对视角方向的部分高光越明显,和光源无光
  • 计算环境反射时使用普通的二维纹理来代替环境贴图
  • 使用阴影衰减值来混合shadowColor,这样阴影区域会保留角色的纹理细节
  • 边缘高光系数是NdotL和NdotV的共同结果,即那些和光照方向一致、且在模型边缘的地方高光越明显

CharaSkin:皮肤

CharaSkin主要用于渲染皮肤、眼睛、脸蛋、睫毛,这些部分。CharaSkin使用的代码和CharaMain中基本一样,只是精简了一些部分,它去掉了计算环境反射、高光反射的部分,只保留漫反射、边缘高光、和阴影的计算部分。而且,在计算边缘高光时,高光颜色也比CharaMain中的暗了一倍,即只去源颜色的0.5倍。除此之外,皮肤使用的漫反射衰减纹理也与衣服等使用的纹理不同:
【Shader】解读Unity Chan的卡通材质

上一篇:让go语言健壮地并发(三)


下一篇:Golang 通道控制并发 goroutine 2