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