Unity Chan Toon Shader(简称UCTS)是一款专为Unity引擎设计的卡通着色器,旨在满足创作者在创建卡通风格3DCG动画时的需求。UCTS由Unity3DJapan社区开发,是一个开源项目,展示了如何使用Unity创建具有动漫风格的3D角色和场景,提供实时可调的卡通渲染效果,包括阴影、高光、轮廓光等多种效果,以及丰富的参数供用户自定义。
官网链接: 这是官网,日语上手的话,可以直接去官网学习、下载各种资源。
GitHub开源项目链接:这是Github,日语苦手的话,可以直接下载开源项目。
进了项目可以到处摸一摸,Sample Scenes下有不少示例场景,做的很可爱,可以进去看一看。
(可爱)
一、项目内容整理
UCTS项目主要还是为了实现对美术的支持,如果能把这些对美术支持的操作都学会了,之后就可以潇洒地给自己项目组里的美术提供技术支持了。
1.1 编辑器拓展
由于UCTS的Shader设计以功能全面为主要目标,后续实战使用的时候一般以裁剪Shader为主,UCTS的原始Shader有大量的Property供美术表现调整使用,如果按照原本默认的Shader面板来展示的话,势必会乱成一锅粥。这里项目里准备了一系列编辑器拓展脚本来完成这些功能,位于Assets/Toon/Editor下,包括一个编排ShaderProperty的ShaderGUI脚本UTS2GUI,以及3个关联了。
(UTS2GUI把这一部分做得漂漂亮亮的)
(拓展了Material的上下文菜单)
(对应的脚本)
UTS2GUI这个脚本是把项目的Shader面板做的漂亮的关键,但其中的代码都是纯苦力活,而且这个Shader牵涉到的参数很多,相对而言会比较枯燥比较累,不难想象开发时的精神状态。
1.2 后处理效果
Unity的Built-in管线下,后处理效果需要在MonoBehavior的OnRenderImage()、OnPreRender()、OnPostRender()中编写代码,找到使用的Shader并传参。UCTS项目中为了方便操作和管理,后处理效果的实现统一写在了Post-Processing Behavior中,并通过继承ScriptableObject的PostProcessingProfile来控制场景后处理效果的开启和参数。
这堆后处理效果中,比较明显的是Bloom辉光效果,画面中的hdr效果由其实现,对整体画面影响比较显著。
(左:Bloom On 右:Bloom Off)
1.3 着色器和材质
从整个项目的角度来说,编辑器拓展和后处理效果只是锦上添花的一部分,卡通渲染着色器和材质是UCTS项目最重要的部分。材质可以认为是着色器的实例(类似于UE中的材质和材质实例的关系,但实现上不一样),因此我们主要还是从着色器的角度来分析。总体而言,UCTS是一个相对比较全面的卡通渲染着色器,并且为了更好地控制细节,使用了大量的贴图来控制单一着色技术的使用与范围,如使用裁剪贴图Clipping Mask手动控制裁剪区域、使用Transparent Level+Transclipping Mask控制着色的透明度通道、使用MatCap Mask和MatCap Normal控制球面朝向映射的范围和法向等。通过这些手段,我们可以精确地控制渲染结果的细节。
二、UCTS渲染过程
在我们正式进入茫茫多的着色器代码之前,我们最好首先使用帧分析器来分析一下UCTS渲染一帧的流程,以此对着色器中各个Pass之间的顺序关系有个初步了解。打开帧分析器(Windows->Analysis->Frame Debugger),点击Enable,截取当前帧的渲染过程。如无其他光源、物体和额外的渲染过程的话,截取的渲染过程将如下图所示(编辑器模式下可能出现渲染了DepthNormalPass的情况)。
(我截取的一帧,红圈中的DrawCall来自同一Pass)
以当前这一帧为例,渲染的过程大致可以分为以下过程:
1.ShadowCaster Pass,这个Pass主要解决光照贴图和阴影贴图。卡通渲染的角色通常使用分级的阴影,在后面还需要对阴影进行额外的处理,这里是获取默认的情况,即三维模型之间的光照/阴影关系。这个过程还需要细分为三个渲染过程:深度贴图的渲染、光照贴图的渲染、阴影贴图的渲染,其中深度贴图和阴影贴图都是对摄像机而言的,光照贴图是对光源而言的。
(相机深度贴图)
(光照贴图,视角很奇怪是因为光源在头顶)
(阴影贴图,相对于相机而言的)
2.外描边OutlinePass。在UCTS中,外描边的光照模式LightMode和不透明物体的LightMode是相同的,都为ForwardBase,两个Pass之间遵循从上到下的顺序执行(实测两者互换不影响渲染结果,外描边的效果与渲染顺序并没有直接关系)。由于这个角色相对复杂一些,帧分析器的内容不够直观,这里换了个场景截的图。
(渲染结果)
(画完头发的外描边Pass的结果)
3.前向渲染Pass(Forward Pass),最重要的一个Pass,卡通角色渲染的核心Pass,后面会具体展开研究其实现。
(已经接近最终效果)
4.后处理部分,包括运动模糊、辉光等效果,具体的实现代码和相关shader可以在Post-Processing Behaviour组件源码中找到。
(加上辉光之后效果明显好多了,后处理同样很重要)
接下来我们找一个Shader来印证一下渲染顺序,可以从场景中带有Skinned Mesh Renderer组件的任意物体的Material中通过齿轮菜单下的Edit Shader进入其Shader源码。
首先映入眼帘的是茫茫多的Shader Property,在外面是那么的整洁(感谢UTS2GUI.cs),这些全部忽略,实际上Shader的主体非常短,这里可以摘抄大部分。
Shader "UnityChanToonShader/Toon_DoubleShadeWithFeather"
{
Properties
{
...
}
SubShader
{
Tags
{
"RenderType"="Opaque"
}
Pass {
Name "Outline"
Tags
{
"LightMode"="ForwardBase"
}
Cull Front
CGPROGRAM
// #pragma
//アウトライン処理はUTS_Outline.cgincへ.
#include "UCTS_Outline.cginc"
ENDCG
}
Pass
{
Name "FORWARD"
Tags {
"LightMode"="ForwardBase"
}
Cull[_CullMode]
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
//#define UNITY_PASS_FORWARDBASE
#include "UnityCG.cginc"
#include "AutoLight.cginc"
#include "Lighting.cginc"
#pragma multi_compile_fwdbase_fullshadows
#pragma multi_compile_fog
//v.2.0.4
#pragma multi_compile _IS_CLIPPING_OFF
#pragma multi_compile _IS_PASS_FWDBASE
//v.2.0.7
#pragma multi_compile _EMISSIVE_SIMPLE _EMISSIVE_ANIMATION
//
#include "UCTS_DoubleShadeWithFeather.cginc"
ENDCG
}
Pass {
//处理多个光源
Name "FORWARD_DELTA"
Tags {
"LightMode"="ForwardAdd"
}
Blend One One
Cull[_CullMode]
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
//#define UNITY_PASS_FORWARDADD
#include "UnityCG.cginc"
#include "AutoLight.cginc"
#include "Lighting.cginc"
//for Unity2018.x
#pragma multi_compile_fwdadd_fullshadows
#pragma multi_compile_fog
//v.2.0.4
#pragma multi_compile _IS_CLIPPING_OFF
#pragma multi_compile _IS_PASS_FWDDELTA
#include "UCTS_DoubleShadeWithFeather.cginc"
ENDCG
}
Pass {
Name "ShadowCaster"
Tags {
"LightMode"="ShadowCaster"
}
Offset 1, 1
Cull Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
//#define UNITY_PASS_SHADOWCASTER
#include "UnityCG.cginc"
#include "Lighting.cginc"
#pragma fragmentoption ARB_precision_hint_fastest
#pragma multi_compile_shadowcaster
#pragma multi_compile_fog
#pragma target 3.0
//v.2.0.4
#pragma multi_compile _IS_CLIPPING_OFF
#include "UCTS_ShadowCaster.cginc"
ENDCG
}
//ToonCoreEnd
}
FallBack "Legacy Shaders/VertexLit"
CustomEditor "UnityChan.UTS2GUI"
}
这个Shader总共4个Pass,执行先后顺序为ShadowCasterPass->Outline->Forward->Forward_Delta。其中Forward_Delta是用于处理多个光源的,其LightMode为ForwardAdd。多翻阅几个Shader可知,不同后缀的Shader通常是使用的宏不一样,或者是有无Outline Pass,最主要的,也是我们最熟悉的着色器代码实现全都集中在*.cginc里,在不同Shader的不同pass里通#pragma multi_compile 来控制功能的开启/关闭。这里我大概整理了一下,还有一个UCTS_Outline.cginc用于处理外描边,控制这个Pass的有无可以实现外描边的有无。
(shader和cginc)
三、着色过程
这里从shader的pass出发,按从上到下依次介绍各个pass的源码和效果,同一pass下的源码也按照从上到下依次进行。UCTS项目里使用的shader比较多,我这里直接按照源码量最大的shader来介绍。
3.1 *_Tess.shader && UCTS_Tess.cginc
*_Tess.shader主要是支持了OpenGL和DX11系列的表面细分着色器,在对应的Shader Pass中,会有这样几句代码变化:
#pragma vertex tess_VertexInput
#pragma hull hs_VertexInput
#pragma domain ds_surf
//...
#ifdef TESSELLATION_ON
#include "UCTS_Tess.cginc"
#endif
UCTS_Tess.cginc中定义了曲面细分需要的外壳着色器和域着色器,具体可以参考Unity曲面细分着色器详解。简单地说,外壳着色器控制了按什么规则增加多少控制点(hs_VertexInput和hsconst_VertexInput ),域着色器对每一个控制点执行什么样的操作(控制位置、法线等,_ds_VertexInput )。控制点这个概念可以类似于blender中表面细分控制器会多出来的顶点;但是又有所不同,因为是在顶点着色器之后才执行的曲面细分,已经不会再执行顶点着色器的代码了,下一步就是送去表面着色器确定颜色了。
// 常量外壳控制器
// 为Hull Shader的一部分
// tessellation hull constant shader
UnityTessellationFactors hsconst_VertexInput(InputPatch<InternalTessInterp_VertexInput, 3> v)
{
//基于边长的曲面细分,见https://docs.unity.cn/cn/2023.2/Manual/SL-SurfaceShaderTessellation.html
//_TessEdgeLength为输入参数 EdgeLength,见https://docs.unity3d.com/Packages/com.unity.toonshader@0.6/manual/instruction.html
float4 tf = UnityEdgeLengthBasedTess(v[0].vertex, v[1].vertex, v[2].vertex, _TessEdgeLength);
UnityTessellationFactors o;
o.edge[0] = tf.x;
o.edge[1] = tf.y;
o.edge[2] = tf.z;
o.inside = tf.w;
return o;
}
// 控制点外壳着色器
// 每一个控制点调用一次
// tessellation hull shader
[UNITY_domain("tri")] //patch的类型 tri表示三角形面片 quad四边形 isoline等值线
[UNITY_partitioning("fractional_odd")] //细分因子
[UNITY_outputtopology("triangle_cw")] //逆时针绕序
[UNITY_patchconstantfunc("hsconst_VertexInput")] //常量外壳控制器
[UNITY_outputcontrolpoints(3)] //输出3个控制点
InternalTessInterp_VertexInput hs_VertexInput(InputPatch<InternalTessInterp_VertexInput, 3> v, uint id : SV_OutputControlPointID)
{
return v[id];
}
//域着色器
//对于从表面细分做出来的所有顶点,都使用域着色器进行处理
inline VertexInput _ds_VertexInput(UnityTessellationFactors tessFactors, const OutputPatch<InternalTessInterp_VertexInput, 3> vi, float3 bary : SV_DomainLocation)
{
// https://docs.unity3d.com/Packages/com.unity.toonshader@0.6/manual/instruction.html
// uv0 uv1 tn都是直接传 新顶点的位置是新算的,主要是
// vertex.xyz = _TessPhongStrength * (pp[0] * bary.x + pp[1] * bary.y + pp[2] * bary.z) + (1.0f - _TessPhongStrength) * v.vertex.xyz
// 这句 其他都挺直观的传数据
VertexInput v;
v.vertex = vi[0].vertex*bary.x + vi[1].vertex*bary.y + vi[2].vertex*bary.z; //新形成的顶点的模型坐标
float3 pp[3];
for (int i = 0; i < 3; ++i)
pp[i] = v.vertex.xyz - vi[i].normal * (dot(v.vertex.xyz, vi[i].normal) - dot(vi[i].vertex.xyz, vi[i].normal));
v.vertex.xyz = _TessPhongStrength * (pp[0] * bary.x + pp[1] * bary.y + pp[2] * bary.z) + (1.0f - _TessPhongStrength) * v.vertex.xyz;
v.tangent = vi[0].tangent*bary.x + vi[1].tangent*bary.y + vi[2].tangent*bary.z; //这俩也是直接传
v.normal = vi[0].normal*bary.x + vi[1].normal*bary.y + vi[2].normal*bary.z;
v.vertex.xyz += v.normal.xyz * _TessExtrusionAmount; //细分后在法向方向上的移动
v.texcoord0 = vi[0].texcoord0*bary.x + vi[1].texcoord0*bary.y + vi[2].texcoord0*bary.z; //这两个是直接传
v.texcoord1 = vi[0].texcoord1*bary.x + vi[1].texcoord1*bary.y + vi[2].texcoord1*bary.z;
UNITY_TRANSFER_INSTANCE_ID(vi[0], v);
return v;
}
*_Tess.cginc与*.cginc的主要区别是因为在UCTS_Tess.cginc已经定义了VertexInput作为顶点着色器的入参,需要防止重复定义,总体逻辑是相同的。
3.2 UCTS_Outline.cginc
首先看一下在这个cginc被#include前定义的相关宏:
#pragma multi_compile _IS_OUTLINE_CLIPPING_YES
#pragma multi_compile _OUTLINE_NML _OUTLINE_POS
首先是第一个 _IS_OUTLINE_CLIPPING_YES这个宏,在UCTS_Outline.cginc最上面的注释里有写
// #pragma multi_compile _IS_OUTLINE_CLIPPING_NO _IS_OUTLINE_CLIPPING_YES
// _IS_OUTLINE_CLIPPING_YESは、Clippigマスクを使用するシェーダーでのみ使用できる. OutlineのブレンドモードにBlend SrcAlpha OneMinusSrcAlphaを追加すること.
也就是说,如果材质使用的关键词为_IS_OUTLINE_CLIPPING_YES的情况下,外描边Pass会使用裁剪蒙版来进行裁剪,并在Outline的渲染模式添加 Blend SrcAlpha OneMinusSrcAlpha,支持透明度通道。
第二个宏并没有在注释里写,这里就得去查找具体实现来印证了。在UCTS_Outline.cginc的顶点着色器里可以找到:
//vert
//v2.0.4
#ifdef _OUTLINE_NML
//v.2.0.4.3 baked Normal Texture for Outline
o.pos = UnityObjectToClipPos(lerp(float4(v.vertex.xyz + v.normal*Set_Outline_Width,1), float4(v.vertex.xyz + _BakedNormalDir*Set_Outline_Width,1),_Is_BakedNormal));
#elif _OUTLINE_POS
Set_Outline_Width = Set_Outline_Width*2;
float signVar = dot(normalize(v.vertex),normalize(v.normal))<0 ? -1 : 1;
o.pos = UnityObjectToClipPos(float4(v.vertex.xyz + signVar*normalize(v.vertex)*Set_Outline_Width, 1));
#endif
也就是说,使用_OUTLINE_NML的情况下,每一个顶点在着色时的位置会向法向量方向位移Set_Outline_Width的距离;而使用_OUTLINE_POS的情况下,顶点位移的方向是模型原点到顶点的方向,移动距离同样是Set_Outline_Width的距离。查找关键字的引用,可以在UTS2GUI里找到,其处于【Outline Settings】一栏内。
public override void OnGUI(..)
{
//..
if(material.HasProperty("_OUTLINE")){
_Outline_Foldout = Foldout(_Outline_Foldout, "【Outline Settings】");
if (_Outline_Foldout)
{
EditorGUI.indentLevel++;
EditorGUILayout.Space();
GUI_Outline(material);
EditorGUI.indentLevel--;
}
EditorGUILayout.Space();
}
}
void GUI_Outline(Material material)
{
//...
if(outlineMode == _OutlineMode.NormalDirection){
EditorGUILayout.BeginHorizontal();
EditorGUILayout.PrefixLabel("Use Baked Normal for Outline");
//GUILayout.Space(60);
if(material.GetFloat("_Is_BakedNormal") == 0){
if (GUILayout.Button("Off",shortButtonStyle))
{
material.SetFloat("_Is_BakedNormal",1);
}
EditorGUILayout.EndHorizontal();
}else{
if (GUILayout.Button("Active",shortButtonStyle))
{
material.SetFloat("_Is_BakedNormal",0);
}
EditorGUILayout.EndHorizontal();
m_MaterialEditor.TexturePropertySingleLine(Styles.bakedNormalOutlineText, bakedNormal);
}
}
}
}
}
也就是说在shader下改这个字段就可以看到区别了,这里选择了后发的材质进行修改,注意观察后发的外描边变化
(Normal Direction)
(Position Scaling)
相较之下,PositionScaling模式的外描边更为柔和,适合像头发这种形状复杂的物体;而Normal Direction模式下的外描边则更明显一些,适合形状比较简单、需要突出外描边的物体,如四肢。
UCTS_Outline.cginc的其他部分代码可以概括为顶点着色器传参、片元着色器混合颜色,比较常规的着色器流程,这里也不再展开。
3.3 着色模型
DoubleShadeWithFeather.cginc与ShadingGradeMap.cginc这两个cginc是UCTS前向渲染Pass的核心技术源码,UCTS项目中使用的不同shader都是使用预定义宏,从这两个cginc中派生出变体得到的。DoubleShadeWithFeather,意为带羽化的双重阴影,传达了其基本逻辑;ShadingGradeMap意为阴影梯度贴图,表示它的阴影强度能够使用阴影梯度贴图进行更精准的控制。实现这两个技术,就已经可以实现卡通渲染的基本雏形了。
当然,UCTS能够实现更为精准的控制,它不仅支持BaseColor和两层阴影的颜色指定,还能够指定对应层使用的基础贴图,使角色在阳光、阴影下呈现不同程度的细节。如衣服的贴图,在使用BaseColor的情况下褶皱的上部细节更多,而使用一级阴影/二级阴影的情况下褶皱的阴影细节更多;此外,还能够通过贴图控制不同位置的阴影强度,如二次元角色的面部通常直接受阴影照射的话会呈现一个奇怪的阴影状态,通过阴影贴图直接控制其面部可受阴影的位置即可避免这种情况。
(光照和阴影下的贴图细节)
3.3.1 DWF与SGM两种前向Pass的区别
在前向渲染Pass使用了这两个cginc的shader都有名为_utsTechnique的参数用作区分使用的核心技术(DWF或者SGM),而这个参数在对应的.cginc中没有使用到,全局搜索这个参数,可以在UTS2GUI.cs这个编辑器拓展里找到其相关引用。
public override void OnGUI(...)
{
_StepAndFeather_Foldout = Foldout(_StepAndFeather_Foldout, "【Basic Lookdevs : Shading Step and Feather Settings】");
if (_StepAndFeather_Foldout)
{
EditorGUI.indentLevel++;
//EditorGUILayout.Space();
GUI_StepAndFeather(material);
EditorGUI.indentLevel--;
}
_ShadowControlMaps_Foldout = FoldoutSubMenu(_ShadowControlMaps_Foldout, "● Shadow Control Maps");
if (_ShadowControlMaps_Foldout)
{
GUI_ShadowControlMaps(material);
EditorGUILayout.Space();
}
}
void GUI_StepAndFeather(Material material)
{
//根据_utsTechnique改变面板状态
}
void GUI_ShadowControlMaps(Material material)
{
//根据_utsTechnique改变面板状态
}
由编辑器源码可知,这两个技术的区别只存在【Basic Lookdevs : Shading Step and Feather Settings】和【Shadow Control Maps】两个页签下。在使用DoubleShadeWithFeather时,Shadow Control Map使用的是2个Position Map;而使用ShadingGradeMap时,Shadow Control Map使用一张Shading Grade Map以及控制Shading Grade Map的强度和模糊度的两个参数。
(ShadingGradeMap)
(DoubleShadeWithFeather)
这里我们使用一张左白右黑界限分明的贴图来展示控制效果。
(使用的贴图)
(DoubleShadeWithFeather,左使用了1st Shade Map)
(ShadingGradeMap,右使用了ShadingGradeMap)
总而言之,ShadingGradeMap是对DoubleShadeWithFeather的升级,其在细节的控制上有所差别,但大部分逻辑都比较相似。
3.3.2 顶点着色器传参&&片元着色器传参
由于卡渲对模型的顶点并不会进行额外的操作,因此顶点着色器的内容基本就只是传参。这里直接全贴出来。
VertexOutput vert (VertexInput v)
{
VertexOutput o = (VertexOutput)0;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_TRANSFER_INSTANCE_ID(v, o);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o); //支持GPU实例化
o.uv0 = v.texcoord0; //传递主uv
//v.2.0.4
#ifdef _IS_ANGELRING_OFF
//
#elif _IS_ANGELRING_ON
o.uv1 = v.texcoord1; //AngelRing的实现额外使用了UV1,对应地把本来传在uv1~8里的参数往后挤了一位
#endif
o.normalDir = UnityObjectToWorldNormal(v.normal);
o.tangentDir = normalize( mul( unity_ObjectToWorld, float4( v.tangent.xyz, 0.0 ) ).xyz );
o.bitangentDir = normalize(cross(o.normalDir, o.tangentDir) * v.tangent.w); //传递tbn
o.posWorld = mul(unity_ObjectToWorld, v.vertex); //传递顶点对应的世界坐标
o.pos = UnityObjectToClipPos( v.vertex ); //传递裁剪空间坐标
UNITY_TRANSFER_FOG(o,o.pos); //雾
TRANSFER_VERTEX_TO_FRAGMENT(o)
return o;
}
从顶点着色器传出了主uv和AngelRing要用到的uv1、法线、切线、副法线以及顶点位置,在片元着色器中首先对传参进行了一些处理。
float4 frag(VertexOutput i, fixed facing : VFACE) : SV_TARGET
{
UNITY_SETUP_INSTANCE_ID(i);
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i);
i.normalDir = normalize(i.normalDir); //原本的法线方向
float3x3 tangentTransform = float3x3( i.tangentDir, i.bitangentDir, i.normalDir); //准备TBN矩阵
float3 viewDirection = normalize(_WorldSpaceCameraPos.xyz - i.posWorld.xyz); //视角方向
float2 Set_UV0 = i.uv0;
//v.2.0.6
float3 _NormalMap_var = UnpackScaleNormal(tex2D(_NormalMap,TRANSFORM_TEX(Set_UV0, _NormalMap)), _BumpScale);
float3 normalLocal = _NormalMap_var.rgb; //
float3 normalDirection = normalize(mul( normalLocal, tangentTransform )); // Perturbed normals 这三句原本是自动解码法线贴图并生成法线向量的代码,这一句增加了法向量与TBN矩阵的点乘,以得到正确的法线
float4 _MainTex_var = tex2D(_MainTex,TRANSFORM_TEX(Set_UV0, _MainTex));
//...
}
3.3.3 片元着色器-蒙版裁剪
*_Clipping.shader会在前向渲染Pass中声明_IS_CLIPPING_MODE或者_IS_TRANSCLIPPING_ON预定义宏:
//DoubleShadeWithFeather
#pragma multi_compile _IS_CLIPPING_MODE
#pragma multi_compile _IS_CLIPPING_TRANSMODE
//ShadingGradeMap
#pragma multi_compile _IS_TRANSCLIPPING_ON
DoubleShadeWithFeather.cginc中使用_IS_CLIPPING_MODE来控制裁剪蒙版的开启和关闭,同时使用_IS_CLIPPING_TRANSMODE控制裁剪蒙版的剪切结果是否以透明度形式体现;在ShadingGradeMap.cginc中直接把两个开关合在了一起,仅仅区分IS_TRANSCLIPPING_ON和IS_TRANSCLIPPING_OFF。这一段shader的结果为裁剪掉ClippingMask对应值+ClippingLevel后依然小于0.5的区域,并使用Transparency Level来控制透明度值。
//v.2.0.4
#ifdef _IS_TRANSCLIPPING_OFF
//
#elif _IS_TRANSCLIPPING_ON
float4 _ClippingMask_var = tex2D(_ClippingMask,TRANSFORM_TEX(Set_UV0, _ClippingMask));
float Set_MainTexAlpha = _MainTex_var.a;
float _IsBaseMapAlphaAsClippingMask_var = lerp( _ClippingMask_var.r, Set_MainTexAlpha, _IsBaseMapAlphaAsClippingMask ); //使用BaseMap的Alpha代替ClippingMask贴图选项
float _Inverse_Clipping_var = lerp( _IsBaseMapAlphaAsClippingMask_var, (1.0 - _IsBaseMapAlphaAsClippingMask_var), _Inverse_Clipping ); //翻转ClippingMask选项
float Set_Clipping = saturate((_Inverse_Clipping_var+_Clipping_Level));
clip(Set_Clipping - 0.5); //提前clip掉避免后续计算
#endif
...
//最后表面着色器输出颜色之前一步
//v.2.0.4
#ifdef _IS_TRANSCLIPPING_OFF
#ifdef _IS_PASS_FWDBASE
fixed4 finalRGBA = fixed4(finalColor,1);
#elif _IS_PASS_FWDDELTA
fixed4 finalRGBA = fixed4(finalColor,0);
#endif
#elif _IS_TRANSCLIPPING_ON
float Set_Opacity = saturate((_Inverse_Clipping_var+_Tweak_transparency)); //改变透明度,_Tweak_transparency即设置中的Transparency Level,是对透明度通道的直接修正
#ifdef _IS_PASS_FWDBASE
fixed4 finalRGBA = fixed4(finalColor,Set_Opacity); //forwardbase为主光源渲染结果,直接作为透明度使用
#elif _IS_PASS_FWDDELTA
fixed4 finalRGBA = fixed4(finalColor * Set_Opacity,0); //forwardadd为副光源渲染结果,影响颜色结果
#endif
#endif
查看UTS2GUI.cs编辑器拓展源码可知,这一宏的影响在Basic Shader Settings里,提供了ClippingMap和相关参数的选择。
这里Unity Chan的后发由两个材质球控制,TonnShader_Head_Clipping用于控制扎带和头上轻飘飘的飘带,并填补一部分头发,高光强度更高,环境光颜色偏粉红,相对而言更加亮一些;ToonShader_Hair_Clipping用于控制主要的头发,不使用高光,并且控制环境光颜色偏向头发本身的颜色。
将ToonShader_Head_Clipping材质的shader调成Transclipping,把ClippingLevel拉到0.5以上,可以看到此时ClippingMask会失效,更亮、金属感更强的高光材质会直接覆盖整个后发。
3.3.4 光照模型
后续的代码将会对光照模型进行渲染。在正式进入UCTS的光照模型渲染之前,我们先来回忆一下基于物理的渲染PBR的光照模型,也就是Blinn-Phong 反射模型:
color = ambient + diffuse + specular
Blinn-Phong 反射模型指出,射入观察视角的光线由Ambient(环境光)、Diffuse(漫反射光)、Specular(镜面反射光)三部分组成。在不考虑遮挡的情况下,环境光通常反映模型本身的颜色,一般为常数,在模型下基本由采样贴图颜色表示;漫反射表示光线照射到模型上某点时被均匀反射到各个方向时,反射到观察者眼中的颜色,表现为;镜面反射表示光线照射到某点时发生镜面反射,反射光线恰好落在观察者眼中时,在观察者眼中这一部分区域会带有较为清晰的光线本身的颜色。
实际上NPR的反射模型同样离不开Blinn-Phong 反射模型。在赛璐璐风格的卡通画中,通常使用一种或两者阴影颜色进行硬过渡或者软过渡,以反映漫反射特点;使用高光色点缀面向光源的一面,以反映镜面反射特点。DWF的名字主要反映了漫反射这一边的特点(Double Shade With Feather,带虚化的双重阴影)。
(鼠绘效果)
(实际效果)
UCTS角色身上的光影来自两个部分,一是是ShadowCaster产生的光照贴图-阴影贴图这一部分由遮挡产生的光影,称为System Shadow,可以通过System Shadow Level来控制其强弱;二就是如上的漫反射-镜面反射产生的光影,是通过Shader的整个过程产生的。
3.3.5 片元着色器-光照信息
这部分主要是处理了Built-In Light Direction,也就是自带光源,这会使得。,这使得我们能够在某些部件上控制光照方向,保证渲染效果,得到LightDirection和LightColor。
UNITY_LIGHT_ATTENUATION(attenuation, i, i.posWorld.xyz); //传递光照信息
#ifdef _IS_PASS_FWDBASE
//在没有光源的情况下,提供一个默认的光源,这点可以把所有光关闭后看得到
float3 defaultLightDirection = normalize(UNITY_MATRIX_V[2].xyz + UNITY_MATRIX_V[1].xyz);
//v.2.0.5
float3 defaultLightColor = saturate(max(half3(0.05,0.05,0.05)*_Unlit_Intensity,max(ShadeSH9(half4(0.0, 0.0, 0.0, 1.0)),ShadeSH9(half4(0.0, -1.0, 0.0, 1.0)).rgb)*_Unlit_Intensity));
//Built-In Light Direction光照方向
float3 customLightDirection = normalize(mul( unity_ObjectToWorld, float4(((float3(1.0,0.0,0.0)*_Offset_X_Axis_BLD*10)+(float3(0.0,1.0,0.0)*_Offset_Y_Axis_BLD*10)+(float3(0.0,0.0,-1.0)*lerp(-1.0,1.0,_Inverse_Z_Axis_BLD))),0)).xyz); //使用BLD之后的光照方向
float3 lightDirection = normalize(lerp(defaultLightDirection,_WorldSpaceLightPos0.xyz,any(_WorldSpaceLightPos0.xyz))); //在没有光源的情况下,提供一个默认的光源
//使用Built-In Light Direction的情况下,使用customLightDirection
lightDirection = lerp(lightDirection, customLightDirection, _Is_BLD);
//v.2.0.5:
float3 lightColor = lerp(max(defaultLightColor,_LightColor0.rgb),max(defaultLightColor,saturate(_LightColor0.rgb)),_Is_Filter_LightColor);
#elif _IS_PASS_FWDDELTA
float3 lightDirection = normalize(lerp(_WorldSpaceLightPos0.xyz, _WorldSpaceLightPos0.xyz - i.posWorld.xyz,_WorldSpaceLightPos0.w));
//v.2.0.5:
float3 addPassLightColor = (0.5*dot(lerp( i.normalDir, normalDirection, _Is_NormalMapToBase ), lightDirection)+0.5) * _LightColor0.rgb * attenuation; //这句其实就是Lambert光照模型,反映了光照漫反射颜色
float pureIntencity = max(0.001,(0.299*_LightColor0.r + 0.587*_LightColor0.g + 0.114*_LightColor0.b)); //光照强度
float3 lightColor = max(0, lerp(addPassLightColor, lerp(0,min(addPassLightColor,addPassLightColor/pureIntencity),_WorldSpaceLightPos0.w),_Is_Filter_LightColor));
#endif
从代码中可以看出,开启Built-In Light Direction,Shader使用的光方向将使用自定义光线方向customLightDirection 来代替;Light_Filter限制了LightColor的大小。
3.3.6 片元着色器-光照模型
首先是确定该点的BaseColor。影响这一点BaseColor的因素有Base贴图和颜色、1stShade贴图和颜色、2ndShade贴图和颜色、SystemShadow、光源产生的漫反射、以及Position贴图等部分,这里直接对给代码分块写注释。
#ifdef _IS_PASS_FWDBASE
//根据光照颜色计算BaseColor应该呈什么颜色
float3 Set_LightColor = lightColor.rgb;
float3 Set_BaseColor = lerp( (_BaseColor.rgb*_MainTex_var.rgb), ((_BaseColor.rgb*_MainTex_var.rgb)*Set_LightColor), _Is_LightColor_Base );
//v.2.0.5
//根据光照颜色计算1stShade应该呈什么颜色
float4 _1st_ShadeMap_var = lerp(tex2D(_1st_ShadeMap,TRANSFORM_TEX(Set_UV0, _1st_ShadeMap)),_MainTex_var,_Use_BaseAs1st);
float3 Set_1st_ShadeColor = lerp( (_1st_ShadeColor.rgb*_1st_ShadeMap_var.rgb), ((_1st_ShadeColor.rgb*_1st_ShadeMap_var.rgb)*Set_LightColor), _Is_LightColor_1st_Shade );
//v.2.0.5
//根据光照颜色计算2ndShade应该是什么颜色
float4 _2nd_ShadeMap_var = lerp(tex2D(_2nd_ShadeMap,TRANSFORM_TEX(Set_UV0, _2nd_ShadeMap)),_1st_ShadeMap_var,_Use_1stAs2nd);
float3 Set_2nd_ShadeColor = lerp( (_2nd_ShadeColor.rgb*_2nd_ShadeMap_var.rgb), ((_2nd_ShadeColor.rgb*_2nd_ShadeMap_var.rgb)*Set_LightColor), _Is_LightColor_2nd_Shade );
//这边又算了一个Lambert,也就是3.3.4提到的SystemShadow之外的光影部分
float _HalfLambert_var = 0.5*dot(lerp( i.normalDir, normalDirection, _Is_NormalMapToBase ),lightDirection)+0.5; //diffuse = I*(L*N);
//这里采样Position贴图,如3.3.1中所演示,用于判断该位置还是否要直接使用对应shade的颜色
float4 _Set_2nd_ShadePosition_var = tex2D(_Set_2nd_ShadePosition,TRANSFORM_TEX(Set_UV0, _Set_2nd_ShadePosition));
float4 _Set_1st_ShadePosition_var = tex2D(_Set_1st_ShadePosition,TRANSFORM_TEX(Set_UV0, _Set_1st_ShadePosition));
//v.2.0.6
//Minmimum value is same as the Minimum Feather's value with the Minimum Step's value as threshold.
float _SystemShadowsLevel_var = (attenuation*0.5)+0.5+_Tweak_SystemShadowsLevel > 0.001 ? (attenuation*0.5)+0.5+_Tweak_SystemShadowsLevel : 0.0001;
//长难句一号,处理了3.3.4提到的两部分光影,即System Shadow和漫反射产生的光影之间的关系;漫反射光影内部还有两级Step、Feather参数和Position贴图要处理
//细分解就饶了我吧
float Set_FinalShadowMask = saturate(
(1.0 + ( (
lerp( _HalfLambert_var,
_HalfLambert_var*saturate(_SystemShadowsLevel_var),
_Set_SystemShadowsToBase )- (_BaseColor_Step-_BaseShade_Feather)) //lerp到这里
* ((1.0 - _Set_1st_ShadePosition_var.rgb).r - 1.0)) //lerp前的括号到这里
/ (_BaseColor_Step - (_BaseColor_Step-_BaseShade_Feather))
)
);//saturate后的括号到这里
//渲染基础颜色
//长难句二号,这一句话实现了Base_Color、1st_ShadeColor、2nd_ShadeColor之间,以及这三个颜色与FinalShadowMask之间的关系,确定了这一点应该呈现什么颜色
//Composition: 3 Basic Colors as Set_FinalBaseColor
float3 Set_FinalBaseColor = lerp(Set_BaseColor,
lerp(Set_1st_ShadeColor,
Set_2nd_ShadeColor,
saturate((1.0 + ( (_HalfLambert_var - (_ShadeColor_Step-_1st2nd_Shades_Feather)) * ((1.0 - _Set_2nd_ShadePosition_var.rgb).r - 1.0) ) / (_ShadeColor_Step - (_ShadeColor_Step-_1st2nd_Shades_Feather)))))
,Set_FinalShadowMask); // Final Color
//最后颜色在Set_FinalBaseColor中;
在后面加一句return float4(Set_FinalBaseColor, 1),看一下渲染的中间结果
可以看到,这时已经完成了渲染了漫反射以及SystemShadow,因为SystemShadow是在另一个Pass里完成的。按照光照模型的理论,后续应该画高光。
float4 _Set_HighColorMask_var = tex2D(_Set_HighColorMask,TRANSFORM_TEX(Set_UV0, _Set_HighColorMask));
float _Specular_var = 0.5*dot(halfDirection,lerp( i.normalDir, normalDirection, _Is_NormalMapToHighColor ))+0.5; // Specular
float _TweakHighColorMask_var = (saturate((_Set_HighColorMask_var.g+_Tweak_HighColorMaskLevel))*lerp( (1.0 - step(_Specular_var,(1.0 - pow(_HighColor_Power,5)))), pow(_Specular_var,exp2(lerp(11,1,_HighColor_Power))), _Is_SpecularToHighColor ));
float4 _HighColor_Tex_var = tex2D(_HighColor_Tex,TRANSFORM_TEX(Set_UV0, _HighColor_Tex));
float3 _HighColor_var = (lerp( (_HighColor_Tex_var.rgb*_HighColor.rgb), ((_HighColor_Tex_var.rgb*_HighColor.rgb)*Set_LightColor), _Is_LightColor_HighColor )*_TweakHighColorMask_var);
//Composition: 3 Basic Colors and HighColor as Set_HighColor
float3 Set_HighColor = (lerp( saturate((Set_FinalBaseColor-_TweakHighColorMask_var)), Set_FinalBaseColor, lerp(_Is_BlendAddToHiColor,1.0,_Is_SpecularToHighColor) )+lerp( _HighColor_var, (_HighColor_var*((1.0 - Set_FinalShadowMask)+(Set_FinalShadowMask*_TweakHighColorOnShadow))), _Is_UseTweakHighColorOnShadow ));
//Set_HighColor画了高光;
照例看看效果
至此,UCTS的光照模型部分已经实现了,后续还有一些代码在处理渲染的细节。
3.3.7 片元着色器-轮廓光
轮廓光(Rimlight)根据菲涅尔效应产生,在PBR中也是常客。它表明了视线垂直于表面时,反射较弱,而当视线非垂直表面时,夹角越小,反射越明显。这也比较好实现,法线与视角垂直时再附加一个颜色即可。
float4 _Set_RimLightMask_var = tex2D(_Set_RimLightMask,TRANSFORM_TEX(Set_UV0, _Set_RimLightMask));
float3 _Is_LightColor_RimLight_var = lerp( _RimLightColor.rgb, (_RimLightColor.rgb*Set_LightColor), _Is_LightColor_RimLight );
float _RimArea_var = (1.0 - dot(lerp( i.normalDir, normalDirection, _Is_NormalMapToRimLight ),viewDirection));
float _RimLightPower_var = pow(_RimArea_var,exp2(lerp(3,0,_RimLight_Power)));
float _Rimlight_InsideMask_var = saturate(lerp( (0.0 + ( (_RimLightPower_var - _RimLight_InsideMask) * (1.0 - 0.0) ) / (1.0 - _RimLight_InsideMask)), step(_RimLight_InsideMask,_RimLightPower_var), _RimLight_FeatherOff ));
float _VertHalfLambert_var = 0.5*dot(i.normalDir,lightDirection)+0.5;
float3 _LightDirection_MaskOn_var = lerp( (_Is_LightColor_RimLight_var*_Rimlight_InsideMask_var), (_Is_LightColor_RimLight_var*saturate((_Rimlight_InsideMask_var-((1.0 - _VertHalfLambert_var)+_Tweak_LightDirection_MaskLevel)))), _LightDirection_MaskOn );
float _ApRimLightPower_var = pow(_RimArea_var,exp2(lerp(3,0,_Ap_RimLight_Power)));
float3 Set_RimLight = (saturate((_Set_RimLightMask_var.g+_Tweak_RimLightMaskLevel))*lerp( _LightDirection_MaskOn_var, (_LightDirection_MaskOn_var+(lerp( _Ap_RimLightColor.rgb, (_Ap_RimLightColor.rgb*Set_LightColor), _Is_LightColor_Ap_RimLight )*saturate((lerp( (0.0 + ( (_ApRimLightPower_var - _RimLight_InsideMask) * (1.0 - 0.0) ) / (1.0 - _RimLight_InsideMask)), step(_RimLight_InsideMask,_ApRimLightPower_var), _Ap_RimLight_FeatherOff )-(saturate(_VertHalfLambert_var)+_Tweak_LightDirection_MaskLevel))))), _Add_Antipodean_RimLight ));
//Composition: HighColor and RimLight as _RimLight_var
float3 _RimLight_var = lerp( Set_HighColor, (Set_HighColor+Set_RimLight), _RimLight );
//Rimligh环境光,一般用菲涅尔效应表示
照例看看效果
3.3.8 片元着色器-球面朝向映射
球面朝向映射(MatCap)技术为根据视角法线采样贴图,将采样得到的贴图混合到渲染的基础色上去,实现一个性能较为节省的类镜面反射效果,细节可以参考文献-知乎。在UCTS中,实现了通过为MatCap提供单独的法线贴图,以达到在头发上绘制出发丝走向纹理细节的效果。
仅仅实现MatCap是比较简单的,参考上边那篇知乎就可以了;UCTS这边控制贴图比较多、此外还实现了镜像效果(应该是为格斗游戏设置的,参考罪恶装备角色在两边时的效果),代码非常长,已经没有耐心一点点细看哩。
3.3.9 片元着色器-自发光
简单自发光的实现非常简单,采样自发光贴图和Mask->在最终输出的颜色里加上这个颜色即可。UCTS这边还实现了一个自发光动画,导致这一段也非常吓人,暂时搁置。
float4 _Emissive_Tex_var = tex2D(_Emissive_Tex,TRANSFORM_TEX(Set_UV0, _Emissive_Tex));
float emissiveMask = _Emissive_Tex_var.a;
emissive = _Emissive_Tex_var.rgb * _Emissive_Color.rgb * emissiveMask;
//...
finalColor = saturate(finalColor) + emissive;
3.3.10 片元着色器-环境光混合
这一部分也比较简单,采样法线方向的环境光强度和颜色->在最终输出的颜色里加上这个颜色即可。
//v.2.0.6: GI_Intensity with Intensity Multiplier Filter
float3 envLightColor = DecodeLightProbe(normalDirection) < float3(1,1,1) ? DecodeLightProbe(normalDirection) : float3(1,1,1);
float envLightIntensity = 0.299*envLightColor.r + 0.587*envLightColor.g + 0.114*envLightColor.b <1 ? (0.299*envLightColor.r + 0.587*envLightColor.g + 0.114*envLightColor.b) : 1;
//..
finalColor = saturate(finalColor) + (envLightColor*envLightIntensity*_GI_Intensity*smoothstep(1,0,envLightIntensity/2))
3.3.11 多光源-ForwardAdd Pass
ForwardAdd Pass用于处理多光源,相比于ForwardBase Pass,它不再去处理诸如轮廓光RimLight、球面朝向映射(MatCap),环境光混合等技术,主要关注于光照模型和自发光的实现,并且在最终输出颜色时将透明度通道置0。
四、总结
本文主要对UCTS项目里的一些技术进行了学习和分享;原本计划还有第四章后处理效果,但码字比我想象的累,就放在下一篇文章中研究实现了。