Unity——卡通渲染实现
阅读原文时间:2022年01月04日阅读:1

效果展示:

原模型:

一、简单分析

卡通渲染又叫非真实渲染(None-Physical Rendering-NPR),一般日漫里的卡通风格有几个特点:

1.人物有描边

2.有明显的阴影分界线,没有太平滑的过渡

以下就根据这两点来实现卡渲效果;

二、描边

实现描边方式多种,比如卷积区分边界;

这里使用更简单的两个Pass,一个只用纯色画背面,利用法线外扩顶点,根据深度的不同这个纯色的背面会被显示出来,同时又不会遮挡正面;

Pass
{
    Tags {"LightMode"="ForwardBase"}
    //裁剪正面,只画背面
    Cull Front

    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    #include "UnityCG.cginc"

    half _OutlineWidth;
    half4 _OutLineColor;

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

    struct v2f
    {
        float4 vertColor : TEXCOORD0;
        float4 pos : SV_POSITION;
    };

    v2f vert (a2v v)
    {
        v2f o;
        UNITY_INITIALIZE_OUTPUT(v2f, o);

        //顶点沿着法线方向外扩
        o.pos = UnityObjectToClipPos(float4(v.vertex.xyz + v.normal * _OutlineWidth * 0.1 ,1));

        o.vertColor = fixed4(v.vertColor.rgb,1.0);
        return o;
    }

    half4 frag(v2f i) : SV_TARGET
    {
        return half4(_OutLineColor.rgb * i.vertColor.rgb, 0);
    }
    ENDCG
}

摄像机远近边缘线粗细不同

由于世界坐标系下做外扩,摄像机里物体远近会影响法线外扩的多少;

解决方案,在NDC坐标系下法线外扩;

//顶点着色器替换以下代码
float4 pos = UnityObjectToClipPos(v.vertex);

//摄像机空间法线
float3 viewNormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal.xyz);

//将法线变换到NDC空间,投影空间*W分量
float3 ndcNormal = normalize(TransformViewToProjection(viewNormal.xyz)) * pos.w;

//xy两方向外扩
pos.xy += 0.01 * _OutlineWidth * ndcNormal.xy * v.vertColor.a;
o.pos = pos;

上下和左右边缘线粗细不同

NDC空间是正方形,而视口宽高比是长方体,导致描边上下和左右的粗细不统一;

解放方案,根据屏幕宽高比缩放法线再外扩;

//将近裁剪面右上角位置的顶点变换到观察空间
//unity_CameraInvProjection摄像机矩阵逆矩阵,UNITY_NEAR_CLIP_VALUE近截面值,DX:0,OpenGL-1.0;_ProjectionParams.y摄像机近截面
float4 nearUpperRight = mul(unity_CameraInvProjection, float4(1, 1, UNITY_NEAR_CLIP_VALUE, _ProjectionParams.y));

//求得屏幕宽高比
float aspect = abs(nearUpperRight.y / nearUpperRight.x);
ndcNormal.x *= aspect;

顶点重合法线不连续

模型顶点重合时会出现多条法线,在不同的面上法线不同导致描边不连续;

解决方案,修改模型顶点数据,同顶点多条法线求平均值;

需要和美工协商修改模型数据,这里写了脚本临时修改模型数据;

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);
        }
        Debug.Log("重合顶点平均法线写入成功");
    }

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

细节处理前后对比:

ps:利用模型顶点的四个通道RGBA——对描边粗细显影相机距离缩放进行精细控制,需要美工配合;

三、着色

二分法

将有阴影和没阴影的地方做明显的区分;

half4 frag(v2f i) : SV_TARGET
{
    half4 col = 1;
    half4 mainTex = tex2D(_MainTex, i.uv);
    half3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
    half3 worldNormal = normalize(i.worldNormal);
    half3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);

    //半兰伯特光照模型
    half halfLambert = dot(worldNormal, worldLightDir) * 0.5 + 0.5;

    //_ShadowRange区分阴影范围,_ShadowSmooth控制分界线的柔和程度,求出ramp值(百分比)
    half ramp = smoothstep(0, _ShadowSmooth, halfLambert - _ShadowRange);

    //根据ramp值插值取样,将阴影和main颜色混合
    half3 diffuse = lerp(_ShadowColor, _MainColor, ramp);
    diffuse *= mainTex;
    col.rgb = _LightColor0 * diffuse;
    return col;
}

Ramp贴图

使用明显分界的色阶图来取样,使阴影有明显的分界线;

逻辑和二分一样,只是多加个几个色阶;

//_ShadowRange范围取样Ramp贴图
half ramp =  tex2D(_RampTex, float2(saturate(halfLambert - _ShadowRange), 0.5)).r;

高光色阶

卡渲高光和阴影一样,和周围色块有明显的分界线;

half3 specular = 0;
half3 halfDir = normalize(worldLightDir + viewDir);
half NdotH = max(0, dot(worldNormal, halfDir));
//_SpecularGloss控制高光光泽度
half SpecularSize = pow(NdotH, _SpecularGloss);

//_SpecularRange高光范围,_SpecularMulti强度,在范围内显示高光有明显分界
if (SpecularSize >= 1 - _SpecularRange)
{
    specular = _SpecularMulti * _SpecularColor;
}

ilmTexture贴图

《GUILTY GEAR Xrd》中使用的方法,又叫Threshold贴图;

贴图的R通道控制漫反射的阴影阈值,G通道控制高光强度,B通道控制高光范围;

需要和美工配合,没贴图就不测了;

总之万物皆可用贴图来传递信息,rgba代表什么意思可以自行做各种trick;

half4 frag (v2f i) : SV_Target
{
    half4 col = 0;
    half4 mainTex = tex2D (_MainTex, i.uv);
    //取样ilmTexture
    half4 ilmTex = tex2D (_IlmTex, i.uv);
    half3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
    half3 worldNormal = normalize(i.worldNormal);
    half3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);

    //漫反射+阴影
    half3 diffuse = 0;
    half halfLambert = dot(worldNormal, worldLightDir) * 0.5 + 0.5;
    //g通道控制高光强度
    half threshold = (halfLambert + ilmTex.r) * 0.5;
    half ramp = saturate(_ShadowRange  - threshold);
    ramp =  smoothstep(0, _ShadowSmooth, ramp);
    diffuse = lerp(_MainColor, _ShadowColor, ramp);
    diffuse *= mainTex.rgb;

    half3 specular = 0;
    half3 halfDir = normalize(worldLightDir + viewDir);
    half NdotH = max(0, dot(worldNormal, halfDir));
    half SpecularSize = pow(NdotH, _SpecularGloss);
    //b通道控制高光遮罩
    half specularMask = ilmTex.b;
    if (SpecularSize >= 1 - specularMask * _SpecularRange)
    {
        //g控制高光强度
        specular = _SpecularMulti * (ilmTex.g) * _SpecularColor;
    }

    col.rgb = (diffuse + specular) * _LightColor0.rgb;
    return col;
}

【翻译】西川善司「实验做出的游戏图形」「GUILTY GEAR Xrd -SIGN-」中实现的「纯卡通动画的实时3D图形」的秘密,前篇(1)

【翻译】西川善司「实验做出的游戏图形」「GUILTY GEAR Xrd -SIGN-」中实现的「纯卡通动画的实时3D图形」的秘密,前篇(2)

【翻译】西川善司的「实验做出的游戏图形」「GUILTY GEAR Xrd -SIGN-」中实现的「纯卡通动画的实时3D图形」的秘密,后篇

三渲二加点边缘泛光会增加立体感,让画质更真实;效果如下;

_RimMin、_RimMax控制边缘泛光范围;

smoothstep使过渡平缓;再乘以RimColor,alpha控制强度;

half f =  1.0 - saturate(dot(viewDir, worldNormal));
half rim = smoothstep(_RimMin, _RimMax, f);
rim = smoothstep(0, _RimSmooth, rim);
half3 rimColor = rim * _RimColor.rgb *  _RimColor.a;
col.rgb = (diffuse + specular + rimColor) * _LightColor0.rgb;

用一张贴图来修正边缘泛光的效果;

边缘光的计算使用的是法线点乘视线。在物体的法线和视线垂直的时候,边缘光会很强。在球体上不会有问题,但是在一些有平面的物体,当平面和视线接近垂直的时候,会导致整个平面都有边缘光。这会让一些不该有边缘光的地方出现边缘光。

post-processing官方组件中有bloom效果;

原理:提取图像中较亮区域,存储在纹理中,使用高斯模糊模拟光线扩散效果,将该纹理和原图像混合;过程比较复杂,后面写屏幕后期效果再分析吧;

完整Shader:

Shader "Unlit/CelRenderFull"
{
    Properties
    {
        _MainTex ("MainTex", 2D) = "white" {}
        _IlmTex ("IlmTex", 2D) = "white" {}

        [Space(20)]
        _MainColor("Main Color", Color) = (1,1,1)
        _ShadowColor ("Shadow Color", Color) = (0.7, 0.7, 0.7)
        _ShadowSmooth("Shadow Smooth", Range(0, 0.03)) = 0.002
        _ShadowRange ("Shadow Range", Range(0, 1)) = 0.6

        [Space(20)]
        _SpecularColor("Specular Color", Color) = (1,1,1)
        _SpecularRange ("Specular Range",  Range(0, 1)) = 0.9
        _SpecularMulti ("Specular Multi", Range(0, 1)) = 0.4
        _SpecularGloss("Sprecular Gloss", Range(0.001, 8)) = 4

        [Space(20)]
        _OutlineWidth ("Outline Width", Range(0, 1)) = 0.24
        _OutLineColor ("OutLine Color", Color) = (0.5,0.5,0.5,1)

        [Space(20)]
        _RimMin ("imMin",float) = 1.0
        _RimMax ("RimMax",float) = 2.0
        _RimSmooth("RimSmooth",Range(0.0,1))=0.5
        _RimColor("RimColor",Color) = (1,1,1,1)
    }

    SubShader
    {
        Pass
        {
            Tags { "LightMode"="ForwardBase"}

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_fwdbase

            #include "UnityCG.cginc"
            #include "Lighting.cginc"
            #include "AutoLight.cginc"

            sampler2D _MainTex;
            float4 _MainTex_ST;
            sampler2D _IlmTex;
            float4 _IlmTex_ST;

            half3 _MainColor;
            half3 _ShadowColor;
            half _ShadowSmooth;
            half _ShadowRange;

            half3 _SpecularColor;
            half _SpecularRange;
            half _SpecularMulti;
            half _SpecularGloss;

            half _RimMin;
            half _RimMax;
            half _RimSmooth;
            fixed4 _RimColor;

            struct a2v
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                float3 normal : NORMAL;
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                float2 uv : TEXCOORD0;
                float3 worldNormal : TEXCOORD1;
                float3 worldPos : TEXCOORD2;
            };

            v2f vert (a2v v)
            {
                v2f o = (v2f)0;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                o.worldNormal = UnityObjectToWorldNormal(v.normal);
                o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
                return o;
            }

            half4 frag (v2f i) : SV_Target
            {
                half4 col = 0;
                half4 mainTex = tex2D (_MainTex, i.uv);
                half4 ilmTex = tex2D (_IlmTex, i.uv);
                half3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
                half3 worldNormal = normalize(i.worldNormal);
                half3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);

                half3 diffuse = 0;
                half halfLambert = dot(worldNormal, worldLightDir) * 0.5 + 0.5;
                half threshold = (halfLambert + ilmTex.g) * 0.5;
                half ramp = saturate(_ShadowRange  - threshold);
                ramp =  smoothstep(0, _ShadowSmooth, ramp);
                diffuse = lerp(_MainColor, _ShadowColor, ramp);
                diffuse *= mainTex.rgb;

                half3 specular = 0;
                half3 halfDir = normalize(worldLightDir + viewDir);
                half NdotH = max(0, dot(worldNormal, halfDir));
                half SpecularSize = pow(NdotH, _SpecularGloss);
                half specularMask = ilmTex.b;
                if (SpecularSize >= 1 - specularMask * _SpecularRange)
                {
                    specular = _SpecularMulti * (ilmTex.r) * _SpecularColor;
                }

                half f =  1.0 - saturate(dot(viewDir, worldNormal));
                half rim = smoothstep(_RimMin, _RimMax, f);
                rim = smoothstep(0, _RimSmooth, rim);
                half3 rimColor = rim * _RimColor.rgb *  _RimColor.a;
                col.rgb = (diffuse + specular + rimColor) * _LightColor0.rgb;

                return col;
            }
            ENDCG
        }

        Pass
        {
            Tags {"LightMode"="ForwardBase"}

            Cull Front

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            half _OutlineWidth;
            half4 _OutLineColor;

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

            struct v2f

            {
                float4 vertColor : TEXCOORD0;
                float4 pos : SV_POSITION;
            };

            v2f vert (a2v v)
            {
                v2f o;
                UNITY_INITIALIZE_OUTPUT(v2f, o);

                float4 pos = UnityObjectToClipPos(v.vertex);
                float3 viewNormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.tangent.xyz);
                float3 ndcNormal = normalize(TransformViewToProjection(viewNormal.xyz)) * pos.w;

                float4 nearUpperRight = mul(unity_CameraInvProjection, float4(1, 1, UNITY_NEAR_CLIP_VALUE, _ProjectionParams.y));
                float aspect = abs(nearUpperRight.y / nearUpperRight.x);
                ndcNormal.x *= aspect;

                pos.xy += 0.01 * _OutlineWidth * ndcNormal.xy * v.vertColor.a;
                o.pos = pos;
                o.vertColor = fixed4(v.vertColor.rgb,1.0);
                return o;
            }

            half4 frag(v2f i) : SV_TARGET
            {
                return half4(_OutLineColor.rgb * i.vertColor.rgb, 0);
            }
            ENDCG
        }
    }
    FallBack Off
}