【Unity Shader】Unity Chan的卡通材質


寫在前面

時隔兩個月我終於來更新博客了,之前一直在學東西,做一些項目,感覺沒什么可以分享的就一直沒寫。本來之前打算寫雲彩渲染或是Compute Shader的,覺得時間比較長所以打算先寫個簡單的。

今天掃項目的時候看到了很早之前下載的Unity Chan的項目,其實很早之前就想要分析下里面的卡通效果是怎么做的。

Unity Chan

想必很多人都看到或聽過Unity Chan,也可以說是Unity醬、Unity娘……她數次出現在早期的AR程序中,一個萌娘在現實生活中的一張卡片上跳來跳去的我相信你大概可以想起來一點…據傳,這個二次元生物是島國分公司的Unity發布的吉祥物,並提供了開源素材來吸引島國二次元游戲開發者。鑒於我對二次元世界不甚了解,感興趣的可以在萌娘百科里找到更多介紹。Unity醬的官方資源可以在商店里找到,資源里自帶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包含了一對vs和fs:

  • 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醬描的並不是黑邊,而是在原漫反射貼圖顏色的基礎上加了一點小trick。總體來講,是希望這個描邊的顏色暗於正常渲染的顏色,起到強調邊緣的效果。這個顏色是通過亮度系數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中的絕大多數像素都由於無法通過深度測試而根本不會被調用fs。

這里寫圖片描述

上面的圖對比了原實現和僅把顏色變暗的效果。很明顯,左邊的實現更好通過改變一些飽和度更好地強調了邊緣細節。

CharaMain:衣服和頭發

CharaMain主要用於渲染角色的衣服和頭發,包含了CharaMain.cg的shader有:Unitychan_chara_hair,Unitychan_chara_hair_ds,Unitychan_chara_fuku,Unitychan_chara_fuku_ds。_hair和_fuku的shader代碼其實完全一樣,而_ds是表示是不是雙面渲染,沒有_ds的在渲染時提出了背面(Cull Back),而有_ds的就關閉了剔除(Cull Off)。Unity醬的項目里都是使用了雙面渲染的版本,這大概是為了保證一些單面片也能不穿幫吧。

下面具體看一下里面的代碼。CharaMain中包含了一對vs和fs的實現:

  • vert:頂點變換,計算主紋理(_MainTex)的采樣坐標,計算世界空間下的法線方向、視角方向、光照方向等;
  • frag:主要完成了五個工作:

    1. 計算包含衰減的光照顏色。我們通常計算漫反射是通過對貼圖采樣后再乘以漫反射系數(n點乘l)。而這里的做法是使用法線和觀察方向的點積結果去采樣一張衰減紋理,得到衰減值。然后靠這個衰減值去混合原貼圖顏色和變暗后的原貼圖顏色(其實就是取平方)。這樣得到的效果其實並不能說是漫反射光照,因為並沒有考慮光源方向,而是使用了類似算邊緣高光的方法(算n點乘v)來計算光照衰減。這也是卡通效果里的一些trick吧。附上源碼:

      // 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 );
    2. 計算高光反射。這一部分同樣不按常理出牌,也挺奇葩的。首先計算得到了我們之前所謂的高光反射系數,按正常的寫法是n和h的點乘結果,但這里仍然使用了n和v的點乘。然后,把之前得到的”漫反射系數“和這次的”高光反射系數“以及高光反射的指數部分傳給Cg的lit函數,讓它計算各個光照系數。當然了,我們其實就是為了得到高光反射光照而已,這一步完全可以自己寫代碼實現,它這么寫應該是為了充分利用GPU的一些native實現,提高一些性能。得到高光反射結果后,再和高光反射顏色和原貼圖顏色相乘即可得到最后的高光反射顏色。源碼如下:

      // 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;
    3. 接下來是計算反射部分。這次計算反射向量的部分總算正常了,但是作者這里並沒有使用環境貼圖來進行采樣,而是一張普通的二維紋理。采樣坐標是通過把反射方向從[-1, 1]映射到[0, 1]來實現的。這樣得到了初始的反射顏色。隨后,調用GetOverlayColor函數來計算原光照結果和反射顏色混合后的結果,GetOverlayColor中也是各種trick。然后,使用反射遮罩值來混合之前的計算結果和反射結果,並和顏色屬性以及光源顏色相乘得到結果。源碼:

      // 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;

      上面最后還計算了該像素的透明度,也就是漫反射貼圖、顏色屬性和光源顏色的透明度的乘積。它會作為輸出像素的透明通道值。

    4. 計算陰影。這部分計算也挺有意思的,它並沒有直接使用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
    5. 最后是計算邊緣高光。眾所周知邊緣高光是卡通效果的必備效果。不同的是,這里還使用了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醬的手臂衣服依次添加上面四個步驟的結果。總結一下它里面用到的一些trick:

  • 計算了一個全局的shadowColor,它其實就是漫反射紋理采樣結果的平方,效果就是比原貼圖顏色暗了一點。
  • 漫反射計算不需要考慮光照方向,而是使用n和v的點乘來計算衰減,這個衰減將會混合上面的shadowColor和正常的顏色貼圖。得到的效果是模型邊緣部分會較暗
  • 高光反射的部分同樣不考慮光照方向,而是使用n和v的點乘。得到的效果是正對視角方向的部分高光越明顯,和光源無光
  • 計算環境反射時使用普通的二維紋理來代替環境貼圖
  • 使用陰影衰減值來混合shadowColor,這樣陰影區域會保留角色的紋理細節
  • 邊緣高光系數是NdotL和NdotV的共同結果,即那些和光照方向一致、且在模型邊緣的地方高光越明顯

CharaSkin:皮膚

CharaSkin主要用於渲染皮膚、眼睛、臉蛋、睫毛,這些部分。CharaSkin使用的代碼和CharaMain中基本一樣,只是精簡了一些部分,它去掉了計算環境反射、高光反射的部分,只保留漫反射、邊緣高光、和陰影的計算部分。而且,在計算邊緣高光時,高光顏色也比CharaMain中的暗了一倍,即只去源顏色的0.5倍。除此之外,皮膚使用的漫反射衰減紋理也與衣服等使用的紋理不同:

這里寫圖片描述

越接近邊緣的部分皮膚顏色越趨近於肉色。

總結

總體來說,Unity醬里面的shader有兩個值得學習的地方:

  • 描邊。描邊的顏色並不是取黑色,而是對原紋理顏色進行逐分量處理后的一個加強顏色。
  • 光照模型。很多trick:
    • 漫反射系數是靠NdotV來對衰減紋理采樣得到的,然后對兩個顏色值進行混合。衰減紋理可以用於控制不同材質的漫反射效果,例如皮膚使用的衰減紋理是由白到肉色漸變,而衣服等其他材質使用的衰減紋理是由黑到白;
    • 環境反射使用二維紋理來代替;
    • 陰影計算是使用衰減值來混合兩種顏色;
    • 邊緣高光是NdotV和NdotL共同作用的結果。

還有一些優化細小的問題:

  • 為了優化在移動平台的性能,都使用了half精度而非float精度;
  • 把高光反射顏色(RGB)和反射遮罩值(A)存儲在了一張紋理中。

寫在最后

這種風格化效果實現的一個特點就是,很多效果其實並不符合任何物理原理,而是為了eye candy而使用的trick。一方面好處是計算量比較小,不需要滿足物理模型那樣進行復雜計算,但另一方面難度也不見得減少了多少,畢竟這些trick也不是一下子就能想到,要試驗好久才能得到滿意的效果。多看多寫吧,總沒壞處。


注意!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系我们删除。



 
粤ICP备14056181号  © 2014-2020 ITdaan.com