# 卡通渲染学习记录

本文记述三渲二学习历程,打算分别采用 Unity 和 Unreal 进行实现。

# 卡通渲染原理

本文依据参考 知乎

# Unity 实现篇

# 描线

# 法线外扩

Unity 中实现法线外扩非常简单,保留模型的初次 Pass 正常渲染,在第二次 Pass 中进行模型沿着法线外扩,并开启 Cull Front (根据三角形朝向裁剪,这里裁剪了正面,只渲染背面)
Shader 代码如下

Shader "OutLine"
{
    Properties
    {
        _OutLineWidth ("OutLine Width", Range(0,1)) = 0.5
        _OutLineColor ("OutLine Color", Color) = (0.9,0.4,0.4,1)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100
        Pass
        {
            // 物体渲染逻辑
        }

       
        // 第二pass - 画轮廓
        Pass
        {
            Cull Front
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            half4 _OutLineColor;
            half _OutLineWidth;

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                float3 normal : NORMAL;
                float3 tangent : TANGENT;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(float4(v.vertex.xyz + v.normal*_OutLineWidth*0.1,1.0f)); //模型在Object空间进行扩张
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                return _OutLineColor;
            }
            ENDCG
        }
    }
}

效果如下
0
1


可以很明显的看到,随着摄像机的移动,描边的粗细也会随之变化。这是因为我们是在 Object 空间进行缩放的。渲染管线为 Object -> Clip(-w,w) -> NDC(-1,1) -> Screen(NDC * [0,Width][0,Height]) ,我们想要粗细在 Screen 空间下相等,那么就必须在 NDC 下相等。NDC 为 Clip 空间除以 w 而来,不同的 w 就是引起 Object 中相同距离到 NDC 距离不同的罪魁祸首。因此我们对于每一个着色点扩张的距离乘上其对应的 w 即可。
其中,Object 空间变换到 Clip 空间会经历以下过程 Object (*world)->World (*view)-> View (*projection)-> Clip ,是最为熟知的 MVP 变换。我们要模拟 Clip 空间中的扩张。而在投影变换之后,z,也就是深度信息已经对于画面偏移没有作用,我们只需要在 x,y 上扩张即可。
代码如下

// 第二pass - 画轮廓
        Pass
        {
            Cull Front
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            half4 _OutLineColor;
            half _OutLineWidth;
            float _AspectRatio;

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                float3 normal : NORMAL;
                float3 tangent : TANGENT;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 pos : SV_POSITION;
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(float4(v.vertex.xyz,1.0f)); // 先进行常规MVP变换
                // 接下来 找到Clip空间下的法线方向
                float3 norm = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal); // 从物体空间到视图空间 - 从这儿开始z是深度,对于扩张就无用了
                float2 extendDir = normalize(TransformViewToProjection(norm.xy));   // 从视图空间到裁剪空间
                float2 extendVec = extendDir * (o.pos.w *_OutLineWidth * 0.1);

                o.pos.xy += extendVec;
                
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                return _OutLineColor;
            }
            ENDCG
        }

此时可以发现摄像机的移动不会再影响粗细。但是,在长宽比偏离 1 的画面中会导致粗细不一,这是因为 NDC 最后转到屏幕空间还需要乘上 width 和 height。

2

那么我们只需在此提前将长宽分别单位化即可。

 v2f vert (appdata v)
    {
        v2f o;
        o.pos = UnityObjectToClipPos(float4(v.vertex.xyz,1.0f)); // 先进行常规MVP变换
        // 接下来 找到Clip空间下的法线方向
        float3 norm = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal); // 从物体空间到视图空间 - 从这儿开始z是深度,对于扩张就无用了
        float2 extendDir = normalize(TransformViewToProjection(norm.xy));   // 从视图空间到裁剪空间
        float2 extendVec = extendDir * (o.pos.w *_OutLineWidth * 0.1);

        // 长宽单位化
        float2 screenSize = float2(_ScreenParams.x, _ScreenParams.y);
        extendVec.x *= 1000/  _ScreenParams.x;
        extendVec.y *= 1000/_ScreenParams.y;

        o.pos.xy += extendVec;
        
        return o;
    }

我们来看一下效果
3

现在还有最后一个显著的问题,就是以上 shader 只对法线连续的有效。
4

我们可以自己撰写脚本

public class PlugTangentTools
{
    [MenuItem("Tools/模型平均法线写入切线数据")]
    public static void WirteAverageNormalToTangentToos()
    {
        MeshFilter[] meshFilters = Selection.activeGameObject.GetComponentsInChildren<MeshFilter>();
        foreach (var meshFilter in meshFilters)
        {
            Mesh mesh = meshFilter.sharedMesh;
            WirteAverageNormalToTangent(mesh);
        }

        SkinnedMeshRenderer[] skinMeshRenders = Selection.activeGameObject.GetComponentsInChildren<SkinnedMeshRenderer>();
        foreach (var skinMeshRender in skinMeshRenders)
        {
            Mesh mesh = skinMeshRender.sharedMesh;
            WirteAverageNormalToTangent(mesh);
        }
    }

    private static void WirteAverageNormalToTangent(Mesh mesh)
    {
        var averageNormalHash = new Dictionary<Vector3, Vector3>();
        for (var j = 0; j < mesh.vertexCount; j++)
        {
            if (!averageNormalHash.ContainsKey(mesh.vertices[j]))
            {
                averageNormalHash.Add(mesh.vertices[j], mesh.normals[j]);
            }
            else
            {
                averageNormalHash[mesh.vertices[j]] =
                    (averageNormalHash[mesh.vertices[j]] + mesh.normals[j]).normalized;
            }
        }

        var averageNormals = new Vector3[mesh.vertexCount];
        for (var j = 0; j < mesh.vertexCount; j++)
        {
            averageNormals[j] = averageNormalHash[mesh.vertices[j]];
        }

        var tangents = new Vector4[mesh.vertexCount];
        for (var j = 0; j < mesh.vertexCount; j++)
        {
            tangents[j] = new Vector4(averageNormals[j].x, averageNormals[j].y, averageNormals[j].z, 0);
        }
        mesh.tangents = tangents;
    }
}

在 Unity 中新建 C# 脚本,导入上面的代码并关闭编辑器。等待编译后点击 Unity 上方菜单的工具栏(要先选中你要进行平均法线的对象)。随后更改 OutLine.shader 即可。
5
在先前的人物模型上尝试如下
6
这个差别不是特别大,主要原因是第一我调整了线比较细,先前即使有裂缝也比较难看出;第二是本身的模型法线就比较连续。想看差异化的效果可以参考文前所给的知乎链接中的模型。

# 着色

# 准备

根据各个贴图的定义,先把各个数据采样出来

// Compute Lighting
half3 normal = normalize(i.normal_world);
half3 ViewDir = normalize(_WorldSpaceCameraPos.xyz - i.pos_world);
half3 LightDir = normalize(_WorldSpaceLightPos0.xyz);
half3 H = normalize(LightDir + ViewDir);
half3 VdotL = dot(ViewDir,LightDir);
half3 NdotH = dot(normal,H);
half3 NdotV = dot(normal,ViewDir);
half3 NdotL = dot(normal,LightDir);

// sample the texture
// Base Map
half4 baseMap = tex2D(_BaseMap, uv0);
half3 baseColor = baseMap.rgb;
// SSS Map
half4 sssMap = tex2D(_SSSMap, uv0);
half3 sssColor = sssMap.rgb;
// ILM Map
half4 ilmMap = tex2D(_ILMMap, uv0);
half specular_intensity = ilmMap.r;
float diffuse_control = ilmMap.g;
half specular_range = ilmMap.b;
half inner_line = ilmMap.a;
// ao
half ao = i.vertex_color.r;

# 风格化 Diffuse

风格化 diffuse 的基本原理就是色阶化,根据漫反射 NdotL 的值来色阶化。

// Toon Diffuse
half3 halfLambert = 0.5 * (NdotL + 1);
//half threshold = (halfLambert + diffuse_control) * 0.5;
half threshold2 = halfLambert > _ToonThreshold ? 1 : 0;

half3 ToonDiffuse = lerp(sssColor,baseColor,threshold2);

7
然后我们要加上 ilm.g 通道对于阴影的控制。根据对于贴图的查询,我们可以发现这里的 diffuse_control 是非常小的值,基本在 0~0.2 之间。这里的 0.3 也可以提升为参数。

// Toon Diffuse
half3 halfLambert = 0.5 * (NdotL + 1);
half threshold = halfLambert + _ShadowPYControl*(diffuse_control - 0.3) > _ToonThreshold ? 1 : 0;

half3 ToonDiffuse = lerp(sssColor,baseColor,saturate(threshold));

8
可以看到头发末梢更容易进入阴影。
最后加上 ao 项

half threshold = halfLambert*ao + _ShadowPYControl*(diffuse_control - 0.3) > _ToonThreshold ? 1 : 0;

9
可以看到裙底,头发等处的阴影更加稳定。

# 风格化高光

风格化高光也是在原有的 Phong 氏高光上进行色阶化。

// Toon Specular
half3 ToonSpecular=0;
NdotH = max(0,NdotH*ao);
half SpecularSize = pow(NdotH, _SpecularGloss);
if(SpecularSize >= 1-specular_range*_specularThreshold){
    ToonSpecular = specular_intensity * 3;
}
// col = inner_line * half4(saturate(ToonSpecular)*baseColor* specular_intensity + ToonDiffuse,1.0f);
col = half4(ToonSpecular + ToonDiffuse,1.0f);
return col;

10
最后记得乘上本衬线。\

# 卡通头发

使用 Kajiya-Kay

// Toon Hair Specular
half3 ToonSpecular=0;
float3 shiftedTangent = normalize(bitangent + normal * _Tshift);
float TdotH = dot(shiftedTangent,H);
float sinTH = sqrt(1.0-TdotH*TdotH);
float dirAtten = smoothstep(-1.0f,0.0,TdotH);
ToonSpecular = dirAtten * pow(sinTH,_SpecularGloss) * _HairSpecular;

模拟天使环如下
17
18
这里我丢失了一张纹理,区分头发和面部的一个 MASK,所以给皮肤也上了,大家将就看。

# 边缘光

边缘光是用来模拟边缘发光。边缘可以根据法线的朝向来估计。当 N 与 V 大致垂直时一般就是在边缘。

// Edge Light
half3 EdgeLight = 0.0f;
half f = 1.0f - saturate(NdotV);
col = half4(half3(f,f,f),1.0f);

材质球示例如下

12

可以看到边缘为白。我们引入三个可调节参数,分别调节边缘光的强度,颜色和范围。

// Edge Light
half3 EdgeLight = 0.0f;
half priEdgeLight = 1.0f - saturate(NdotV);
half powEdgeLight = pow(priEdgeLight,_EdgeLightGloss);
half f = saturate(step(_EdgeLightThreshold,powEdgeLight));

// col = half4(inner_line*(ToonSpecular + ToonDiffuse),1.0f) ;
col = half4(half3(f,f,f),1.0f);

13
可以看到收获了较为硬的边缘光。
这里的边缘光四周都有,但是一般来说我们只想着光照亮部拥有边缘光。因此我们需要做一个 Mask。那么如何才能获取到那边是光照的方向呢?答案是通过 Diffuse 来获取,通过一个阈值来截断。

 // Edge Light
half3 EdgeLight = 0.0f;
half priEdgeLight = 1.0f - saturate(NdotV);
half powEdgeLight = pow(priEdgeLight,_EdgeLightGloss);
half f = saturate(step(_EdgeLightThreshold,powEdgeLight));
half EdgeLightMask = halfLambert > _EdgeLightRange ? 1 : 0;
EdgeLight = f * EdgeLightMask * _EdgeLightColor;

// col = half4(inner_line*(ToonSpecular + ToonDiffuse),1.0f) ;
col = half4(EdgeLight,1.0f);

14

与原始图案相加,这里将头发的边缘光特地放大了。有点内味了。

15
16

Unity 部分到此结束。

更新于 阅读次数

请我喝[茶]~( ̄▽ ̄)~*

Yian Zhao 微信支付

微信支付

Yian Zhao 支付宝

支付宝

Yian Zhao 贝宝

贝宝