Unity 模仿 MMD 卡通渲染的描边

本文由 简悦 SimpRead 转码, 原文地址 www.bilibili.com

作者:昨夜丶Sakuya

偶然发现 MMD 描边,眼睛周围并没有被描边,感觉效果挺好,于是想拿 Unity 模仿一下,最后用两种方法从效果上实现了。

偶然发现 MMD 描边,眼睛周围并没有被描边,感觉效果挺好,于是想拿 Unity 模仿一下,最后用两种方法从效果上实现了。

MMD 渲染

放大观察 MMD 的描边,有转角断层的现象,感觉也是沿法线外扩,所以先用这个方法实现对模型描边。这次我创建的是 URP 的项目用来练习新的 ShaderLab 写法。

Shader "Unlit/SakuyaToon"
{
    Properties
    {
        [Header(Toon Settings)]
        [Space(10)]
        [NoScaleOffset] _MainTex ("Base Albedo", 2D) = "white" {}
        [Header(Outline Settings)]
        [Space(10)]
        _OutlineWidth ("Outline Width", Range(0, 1)) = 1
        _OutLineColor ("OutLine Color", Color) = (0,0,0,1)

    }
    SubShader
    {
        Tags {
            "RenderPipeline" = "UniversalPipeline"
            "Queue" = "Transparent"
            "RenderType"="Transparent"
        }

        //描边pass
        Pass{
            Tags {"LightMode"="SRPDefaultUnlit"}
            Cull Front
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            float _OutlineWidth;
            float4 _OutLineColor;

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

            struct v2f
             {
              float4 pos : SV_POSITION;
            };

            v2f vert (a2v v)
            {
              v2f o;
              //顶点沿着法线方向外扩
              o.pos = UnityObjectToClipPos(float4(v.vertex.xyz + normalize(v.normal) *(_OutlineWidth/1000) ,1));
                return o;
            }

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

            ENDHLSL
        }

        //着色pass
        Pass
        {
            Tags {"LightMode"="UniversalForward"}
            Cull Back

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag

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

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

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

            sampler2D _MainTex;
            float4 _MainTex_ST;

            v2f vert (a2v v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 mainTex = tex2D(_MainTex, i.uv);
                return mainTex;
            }
            ENDHLSL
        }
    }
}

方法原理很简单,在第一个 Pass 剔除正面,顶点着色器将顶点沿着法线方向移动【描边宽度】个距离,片元着色器直接返回【描边颜色】。

Unity 法线外扩描边

可以看到眼睛部分被描了出来,这就是本次需要优化的部分。

观察 MMD 的描边,眼睛部分的内描边被剔除,但如果我们关闭深度写入把渲染队列排到天空盒后,那么描边从结果上只会保留外描边,而且睫毛的 mesh 突出成为外描边的时候,他也没有进行描边,所以我判断并不是不关闭深度写入,而是以某种方法控制了眼睛部分的顶点外扩。

方法一:使用 Mask 贴图控制

用 blender 看了下 UV 的对应,然后在 PS 做了一张 Mask 贴图,黑色为 0 白色为 1,最后在顶点外扩时相乘这个 Mask。

面部 Mask

修改 shader:

Properties
  {
      ...
      [NoScaleOffset] _OutLineMask("OutLine Mask",2D) = "white" {}
      ...
  }
//描边pass
Pass{
  ...
    sampler2D _OutLineMask;
  ...
}
struct a2v
{
  ...
  float2 uv : TEXCOORD0;
  ...
};
v2f vert (a2v v)
{
  ...
  //采样OutlineMask纹理
  fixed4 outLineMaskColor = tex2Dlod(_OutLineMask, float4(v.uv, 0, 0));
  //顶点沿着法线方向外扩
  o.pos = UnityObjectToClipPos(float4(v.vertex.xyz + normalize(v.normal) * (_OutlineWidth/1000) *outLineMaskColor.r,1));
  ...
}

最终效果

这样做虽然可行不过每次都要做一个贴图,感觉有点繁琐,而且可能不止需要控制一个材质,比如这个例子就还需要做一张头发的 Mask,MMD 的贴图里也不会有这种东西。

方法二:脚本传入双眼位置按距离剔除

用 Mono 脚本传入双眼位置,在世界坐标下计算距离做剔除。

修改 Shader:

Properties
{
    ...
    _EyeLPos("EyeLPos",Vector) = (0,0,0)
    _EyeRPos("EyeRPos",Vector) = (0,0,0)
    _OutlineThreshold("OutlineThreshold",Range(0,5)) = 0
   ...
}
//描边pass
Pass{
  ...
    float4 _EyeLPos;
    float4 _EyeRPos;
    float _OutlineThreshold;
  ...
}
v2f vert (a2v v)
{
  v2f o;
  float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
  float dis2EyeL = length(worldPos - _EyeLPos);
  float dis2EyeR = length(worldPos - _EyeRPos);
  if (dis2EyeL > (_OutlineThreshold / 100) && dis2EyeR > (_OutlineThreshold / 100)) {
    v.vertex.xyz += normalize(v.normal) * (_OutlineWidth / 1000);
    }
  o.pos = UnityObjectToClipPos(v.vertex);
  return o;
}

C# 脚本:

using UnityEngine;

[ExecuteInEditMode]
public class ToonShaderScript : MonoBehaviour
{
    public SkinnedMeshRenderer meshRender;
    public Transform eyePosL;
    public Transform eyePosR;

    // Update is called once per frame
    void Update()
    {
       for(int i = 0;i < meshRender.sharedMaterials.Length; i++)
        {
            if (meshRender.sharedMaterials[i].HasProperty("_EyeLPos"))
            {
                meshRender.sharedMaterials[i].SetVector("_EyeLPos", eyePosL.position);
                meshRender.sharedMaterials[i].SetVector("_EyeRPos", eyePosR.position);
            }
        }
    }
}

GIF

最终效果(GIF 图)

MMD 的模型骨骼是全的,也许确实能传入两个眼睛骨骼的位置进行计算。不过我这样算,本质是剔除一个球形范围内的顶点外扩,也许并不适用所有模型。由于我也是渲染小白,不清楚是不是最好的办法,所以这边也只作是为记录,记一下过程,欢迎指点。

评论