目录
写在前面
分析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就行
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个工作:
光照衰减
通常计算漫反射是通过对贴图采样后再乘以漫反射系数(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倍。除此之外,皮肤使用的漫反射衰减纹理也与衣服等使用的纹理不同: