【Unity Shader】Unity Chan的卡通材质
原文地址:http://blog.csdn.net/candycat1992/article/details/51050591
写在前面
时隔两个月,我终于更新博客了。此前一直在学习新知识、参与项目开发,觉得没有值得分享的内容,所以一直未更新。原本打算撰写关于云彩渲染或Compute Shader的文章,但考虑到所需时间较长,便决定先写一篇简单的。
今天在整理项目时,我看到了很早之前下载的Unity Chan项目。其实,我早就想分析其中卡通效果的实现方式了。
Unity Chan
想必很多人都见过或听说过Unity Chan,也有人称她为Unity酱、Unity娘。她多次出现在早期的AR程序中,一个萌娘在现实生活中的卡片上跳动的场景,相信大家或多或少还有印象。据传,这个二次元形象是Unity岛国分公司推出的吉祥物,并且提供了开源素材,以吸引岛国的二次元游戏开发者。由于我对二次元世界了解有限,感兴趣的读者可以在萌娘百科(https://zh.moegirl.org/Unity娘)中获取更多相关介绍。Unity酱的官方资源可以在Unity商店(https://www.assetstore.unity3d.com/en/#!/content/18705)中找到,该资源包自带31个动画和三个内置场景,尚未下载的读者不妨去看看,真的非常可爱!
今天我还发现,Unity酱衣服的拉锁是Unity的logo,十分有爱。
Unity Chan使用的Shader
当然,今天的重点是探讨Unity酱卡通效果的实现方式。在我的博客中,卡通渲染相关内容屡见不鲜,例如【Unity Shader实战】卡通风格的Shader(一)、【Unity Shader实战】卡通风格的Shader(二)、【NPR】漫谈轮廓线的渲染、【Shader拓展】Illustrative Rendering in Team Fortress 2、【NPR】卡通渲染。这次,我们将深入学习成熟项目中卡通渲染的实现方法。
Unity酱的项目包含3个CG文件,具体信息如下: | 文件名 | 用途 | | --- | --- | | CharaOutline | 包含最通用的shader,用于绘制描边效果。 | | CharaMain | 角色使用的主要shader,包含漫反射、阴影、高光、边缘高光、反射等通用的顶点着色器(vs)和片元着色器(fs)实现,用于渲染衣服和头发。 | | CharaSkin | 皮肤使用的shader,包含漫反射、边缘高光和阴影的实现(相较于CharaMain,未计算高光和反射),用于渲染皮肤、眼睛、脸蛋、睫毛。 |
CharaOutline:描边
在所有卡通效果中,描边是必不可少的,但这里的描边并非黑色。其实现方式是将顶点沿着法线方向进行扩张,这与我之前在【Unity Shader实战】卡通风格的Shader(二)中介绍的思路一致。CharaOutline包含一对顶点着色器(vert)和片元着色器(frag):
vert实现
// Vertex shader
v2f vert( appdata_base v )
{
v2f o;
o.uv = TRANSFORM_TEX( v.texcoord.xy, _MainTex );
half4 projSpacePos = mul( UNITY_MATRIX_MVP, v.vertex );
half4 projSpaceNormal = normalize( mul( UNITY_MATRIX_MVP, half4( v.normal, 0 ) ) );
half4 scaledNormal = _EdgeThickness * INV_EDGE_THICKNESS_DIVISOR * projSpaceNormal; // * projSpacePos.w;
scaledNormal.z += 0.00001;
o.pos = projSpacePos + scaledNormal;
return o;
}
上述代码的实现非常简单,先将顶点和法线变换到裁剪坐标空间,然后将顶点沿着法线方向扩张。这里给法线的z分量增加了一个小值,目的是防止描边遮挡正常渲染。不过,这种方法存在与【Unity Shader实战】卡通风格的Shader(二)中提到的相同弊端,即当描边宽度较大时,可能会出现穿帮现象。解决方法可参考该文章,读者也可以对Unity酱的实现进行改进。
frag实现
// Fragment shader
float4 frag( v2f i ) : COLOR
{
float4_t diffuseMapColor = tex2D( _MainTex, i.uv );
float_t maxChan = max( max( diffuseMapColor.r, diffuseMapColor.g ), diffuseMapColor.b );
float4_t newMapColor = diffuseMapColor;
maxChan -= ( 1.0 / 255.0 );
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;
}
Unity酱的描边并非黑色,而是在原漫反射贴图颜色的基础上进行了一些处理。总体而言,希望描边颜色比正常渲染颜色暗,以突出边缘效果。该颜色通过亮度系数BRIGHTNESS_FACTOR、计算得到的新颜色newMapColor和原贴图颜色diffuseMapColor相乘得到。BRIGHTNESS_FACTOR用于控制整体变暗的程度,这里取值为0.8。newMapColor的计算是关键,其初始值为原贴图颜色,然后对不同分量进行不同的颜色处理。值最高的分量颜色保持不变,其他分量通常是原分量乘以变暗系数SATURATION_FACTOR后的结果。为了理解上述代码,我们只需关注lerpVals何时取0、何时取1。分析可知,值最高的分量会取1,因此颜色保持不变;其他分量只要小于最高值,就会取0,通常会得到变暗后的颜色值。最后,描边颜色还乘以了颜色属性_Color和光源颜色_LightColor0,增加了可调节性。
在需要使用描边的shader中,只需声明第二个Pass:
Pass
{
Cull Front
ZTest Less
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
#include "CharaOutline.cg"
ENDCG
}
上述代码只渲染模型背面,并设置只有在小于当前深度时才进行渲染。需要注意的是,描边的Pass声明在第二个Pass,这有助于提高渲染性能,因为描边Pass中的绝大多数像素由于无法通过深度测试,根本不会调用片元着色器。
对比原实现和仅将颜色变暗的效果,可以明显看出,左边的实现通过调整饱和度更好地突出了边缘细节。
CharaMain:衣服和头发
CharaMain主要用于渲染角色的衣服和头发,包含CharaMain.cg的shader有:Unitychan_chara_hair、Unitychan_chara_hair_ds、Unitychan_chara_fuku、Unitychan_chara_fuku_ds。_hair和_fuku的shader代码完全相同,_ds表示是否进行双面渲染。没有_ds的shader在渲染时剔除了背面(Cull Back),而有_ds的则关闭了剔除(Cull Off)。Unity酱的项目中使用的是双面渲染版本,这可能是为了避免单面片出现穿帮现象。
下面详细分析其中的代码。CharaMain包含一对顶点着色器(vert)和片元着色器(frag):
- vert:进行顶点变换,计算主纹理(_MainTex)的采样坐标,计算世界空间下的法线方向、视角方向、光照方向等。
- frag:主要完成以下五个工作:
- 计算包含衰减的光照颜色:通常计算漫反射是通过对贴图采样后乘以漫反射系数(n点乘l),而这里使用法线和观察方向的点积结果对一张衰减纹理进行采样,得到衰减值,然后用该衰减值混合原贴图颜色和变暗后的原贴图颜色(即取平方)。这种方法得到的效果并非传统的漫反射光照,因为没有考虑光源方向,而是采用了类似计算边缘高光的方法(计算n点乘v)来计算光照衰减,这也是卡通效果中的一种技巧。源码如下:
// Falloff. Convert the angle between the normal and the camera direction into a lookup for the gradient float_t normalDotEye = dot( normalVec, i.eyeDir.xyz ); float_t falloffU = clamp( 1.0 - abs( normalDotEye ), 0.02, 0.98 ); float4_t falloffSamplerColor = FALLOFF_POWER * tex2D( _FalloffSampler, float2( falloffU, 0.25f ) ); float3_t shadowColor = diffSamplerColor.rgb * diffSamplerColor.rgb; float3_t combinedColor = lerp( diffSamplerColor.rgb, shadowColor, falloffSamplerColor.r ); combinedColor *= ( 1.0 + falloffSamplerColor.rgb * falloffSamplerColor.a ); - 计算高光反射:这部分的实现也比较特殊,通常计算高光反射系数是n和h的点乘结果,而这里使用n和v的点乘。然后,将之前得到的“漫反射系数”、这次的“高光反射系数”以及高光反射的指数部分传递给Cg的lit函数,计算各个光照系数。实际上,我们只是为了得到高光反射光照,这一步也可以自己编写代码实现,这样写可能是为了充分利用GPU的原生实现,提高性能。得到高光反射结果后,将其与高光反射颜色和原贴图颜色相乘,得到最终的高光反射颜色。源码如下:
// Specular // Use the eye vector as the light vector float4_t reflectionMaskColor = tex2D( _SpecularReflectionSampler, i.uv.xy ); float_t specularDot = dot( normalVec, i.eyeDir.xyz ); float4_t lighting = lit( normalDotEye, specularDot, _SpecularPower ); float3_t specularColor = saturate( lighting.z ) * reflectionMaskColor.rgb * diffSamplerColor.rgb; combinedColor += specularColor; - 计算反射部分:这次计算反射向量的部分较为常规,但作者没有使用环境贴图进行采样,而是使用了一张普通的二维纹理。采样坐标通过将反射方向从[-1, 1]映射到[0, 1]实现。得到初始反射颜色后,调用GetOverlayColor函数计算原光照结果和反射颜色混合后的结果,该函数中也运用了一些技巧。最后,使用反射遮罩值混合之前的计算结果和反射结果,并与颜色属性以及光源颜色相乘。此外,还计算了该像素的透明度,即漫反射贴图、颜色属性和光源颜色的透明度的乘积,作为输出像素的透明通道值。源码如下:
// Reflection float3_t reflectVector = reflect( -i.eyeDir.xyz, normalVec ).xzy; float2_t sphereMapCoords = 0.5 * ( float2_t( 1.0, 1.0 ) + reflectVector.xy ); float3_t reflectColor = tex2D( _EnvMapSampler, sphereMapCoords ).rgb; reflectColor = GetOverlayColor( reflectColor, combinedColor );
combinedColor = lerp( combinedColor, reflectColor, reflectionMaskColor.a ); combinedColor = _Color.rgb _LightColor0.rgb; float opacity = diffSamplerColor.a _Color.a _LightColor0.a;
- **计算阴影**:这部分的计算也很有趣,没有直接使用LIGHT_ATTENUATION乘以之前的结果,而是使用阴影衰减值混合阴影颜色和现有颜色,阴影颜色是漫反射纹理采样结果的平方。这样得到的阴影效果是,在阴影完全覆盖的地方,纹理颜色会变暗。源码如下:
ifdef ENABLE_CAST_SHADOWS
// Cast shadows shadowColor = _ShadowColor.rgb combinedColor; float_t attenuation = saturate( 2.0 LIGHT_ATTENUATION( i ) - 1.0 ); combinedColor = lerp( shadowColor, combinedColor, attenuation );
endif
- **计算边缘高光**:边缘高光是卡通效果的必备元素。这里使用n和l的点乘结果与n和v的点乘结果相乘,计算边缘高光的衰减,然后用该衰减值对一张边缘高光纹理进行采样,得到真正的边缘高光衰减值。源码如下:
// Rimlight float_t rimlightDot = saturate( 0.5 ( dot( normalVec, i.lightDir ) + 1.0 ) ); falloffU = saturate( rimlightDot falloffU ); falloffU = tex2D( _RimLightSampler, float2( falloffU, 0.25f ) ).r; float3_t lightColor = diffSamplerColor.rgb; // 2.0; combinedColor += falloffU lightColor;
对Unity酱的手臂衣服依次添加上述四个步骤的结果如图所示。总结其中使用的技巧:
- 计算了一个全局的shadowColor,即漫反射纹理采样结果的平方,效果是比原贴图颜色暗一些。
- 漫反射计算不考虑光照方向,而是使用n和v的点乘计算衰减,该衰减用于混合shadowColor和正常的颜色贴图,使模型边缘部分较暗。
- 高光反射部分同样不考虑光照方向,使用n和v的点乘,使正对视角方向的部分高光更明显,与光源无关。
- 计算环境反射时使用普通的二维纹理代替环境贴图。
- 使用阴影衰减值混合shadowColor,使阴影区域保留角色的纹理细节。
- 边缘高光系数是NdotL和NdotV共同作用的结果,即与光照方向一致且在模型边缘的地方高光更明显。
### CharaSkin:皮肤
CharaSkin主要用于渲染皮肤、眼睛、脸蛋、睫毛等部分。其使用的代码与CharaMain基本相同,但进行了精简,去掉了计算环境反射和高光反射的部分,只保留了漫反射、边缘高光和阴影的计算部分。此外,在计算边缘高光时,高光颜色比CharaMain中的暗一倍,即只取源颜色的0.5倍。皮肤使用的漫反射衰减纹理也与衣服等使用的纹理不同,越接近边缘的部分,皮肤颜色越趋近于肉色。
## 总结
总体而言,Unity酱的shader有两个值得学习的地方:
- **描边**:描边颜色不是黑色,而是对原纹理颜色进行逐分量处理后的加强颜色。
- **光照模型**:采用了许多技巧,如漫反射系数通过NdotV对衰减纹理采样得到,然后混合两个颜色值;环境反射使用二维纹理代替;阴影计算使用衰减值混合两种颜色;边缘高光是NdotV和NdotL共同作用的结果。
还有一些细微的优化:
- 为了优化移动平台的性能,使用了half精度而非float精度。
- 将高光反射颜色(RGB)和反射遮罩值(A)存储在一张纹理中。
## 写在最后
这种风格化效果的实现特点是,很多效果并不符合物理原理,而是为了视觉效果采用的技巧。一方面,这种方法计算量较小,无需进行复杂的物理模型计算;另一方面,这些技巧并非轻易就能想到,需要经过多次试验才能得到满意的效果。因此,多学习、多实践总是有益的。