二次元卡通渲染——进阶技巧

二次元卡通渲染——进阶技巧

前言

随着《原神》游戏的盛行,国内对于二次元游戏这块儿领域越来越看重了。二次元项目中本身基于日本的卡通动漫而来,所以最后的本质都是为了尽量还原2D立绘,而并不像PBR追求物理正确,只要好看,还原立绘,那么就是成功的。所以说到这里,我们的目标就是还原立绘。

卡通渲染领域,其实有一些卡通风格独有的效果,这里就个人对于二次元和日本动漫的理解,收集了一些卡通渲染中独有的效果,以及个人在这块儿爬过的坑记录下来,抛砖引玉,给大家分享下自己的实现思路,如果能帮助到各位,则甚之好矣(注:本文中的所有代码思路均基于Built in内置管线)。

一、眼睛眉毛穿过头发,俗称飘眉

这个也属于常规效果了,一般做老二次元美术都会要求这个效果,也是动画摄影中很重要的一个评判效果,目前有两种手段,基于深度Depth和基于Stencil。

基于深度Depth代码(劣势:多一个Pass,多一个Draw Call)。

  1. 先画脸部和头发,都保留默认不透明队列即可"Queue" = "Geometry"。
  2. 再画眉毛(需要两个Pass),设置队列在脸部和头发之后"Queue" = "Geometry+10"。
  3. 画眉毛的第一次Pass {ZTest LEqual........}(画没有被头发挡住的部分)。
  4. 画眉毛的第二次Pass {ZTest GEqual......}(画被头发挡住的部分)。

基于Stencil代码。

  1. 先画眉毛,设置眉毛队列 "Queue" = "Geometry-10",同时设置Stencil:
Stencil
{
    Ref 2
    Comp GEqual
    Pass Replace
    Fail Keep
}
  1. 再画脸部和头发,对于头发的Stencil设置:
Stencil
{
    Ref 1
    Comp Greater
    Pass Keep
    Fail Keep
}

注意:不做任何设置情况下,Stencil的默认值是0。

二、自定义控制Bloom区域

这个是比较普通的大众需求了,很多二次元游戏中只让脸部泛光,但身体偏白的区域还是可以不泛光的,当然这个如果是完全自己写后期Bloom效果会容易很多,我们这里通过修改Unity默认的PPSV2,将该效果融进去,因为目前很多公司都直接采用了Unity自带的后处理了,确实方便,效果调整也可以。

大概实现思路:

  1. 正常渲染角色,然后将需要Bloom的遮罩区域,通过设置输出颜色的A通道outcolor.a =黑白区域 ,保存到A通道中,会跟着流水线走到后期所需要用的颜色缓冲区的RT中。

  2. 正常Post Bloom,修改Bloom.shader文件,应用当前屏幕RT的Alpha值。

三、基于深度的额发投影

这个分游戏项目,国内确实也有大部分游戏项目直接不让头发投影解决完事。但是这样会少一层投影关系,立体感可能会减弱,不过对于玩家而言也无所谓了。如果采用Unity自带的Shadowmap,由于Shadowmap是根据灯光方向计算出来,很难控制,比如左边额发投下来的影子合适了,右边就可能投到脸上了,因为灯光方向不好把控,无法做到正好只是整个额发投下来的效果,看上去很薄,很纸片的美好感觉。

参考文章《【Unity URP】以Render Feature实现卡通渲染中的刘海投影》


找到的资源,头发和眼睛在一个网格上,所以眼睛区域也绘制出了投影,真正做的时候肯定会分开的

大概思路:

  1. 画脸部和眼睛(头发以外的其他头部区域Mesh)通过第一个Pass,只写入深度。
  2. 画头发,采用第2个Pass,画出头发区域遮罩(只保留干净的头发区域),黑白图。
  3. 在脸部Shader中采样黑白图,在灯光的摄像机空间,按照灯光方向偏移第2点中的黑白图,得到额发投影。

代码实现(基于Built in管线)。

画头发用的C#部分:

public class HairMaskGenerate : MonoBehaviour
{

    public Renderer faceRenderer1;//脸部Renderer
    public Renderer faceRenderer2;//脸部Renderer
    public Renderer eyeBrowRenderer;//眉毛
    //public Renderer eyeRenderer;//眼睛

    public Renderer hairRenderer;//头发Renderer
    public Material hairMaskMaterial;

    private CommandBuffer cmb = null;
    private RenderTexture hairMaskRT = null;
    private Camera mRTGenerateCamera;

    void Start()
    {
        mRTGenerateCamera = GetComponent<Camera>();
        cmb = new CommandBuffer();
        cmb.name = "Cmb_DrawHairMask";
        hairMaskRT = new RenderTexture(mRTGenerateCamera.pixelWidth, mRTGenerateCamera.pixelHeight, 24);
        cmb.SetRenderTarget(hairMaskRT);
        cmb.ClearRenderTarget(true, true, Color.black);

        //脸部有两部分mesh组成, 只画深度
        cmb.DrawRenderer(faceRenderer1, hairMaskMaterial, 0, 0);
        cmb.DrawRenderer(faceRenderer2, hairMaskMaterial, 0, 0);
        cmb.DrawRenderer(eyeBrowRenderer, hairMaskMaterial, 0, 0);
        //cmb.DrawRenderer(eyeRenderer, hairMaskMaterial, 0, 0);

        //画出头发的区域,黑白遮罩图
        cmb.DrawRenderer(hairRenderer, hairMaskMaterial, 0, 1);
        mRTGenerateCamera.AddCommandBuffer(CameraEvent.BeforeForwardOpaque, cmb);
    }

    // Update is called once per frame
    void Update()
    {
        mRTGenerateCamera.CopyFrom(Camera.main);//统一位置和角度信息,保持和主相机一致
        mRTGenerateCamera.farClipPlane = Camera.main.farClipPlane;
        mRTGenerateCamera.nearClipPlane = Camera.main.nearClipPlane;
        mRTGenerateCamera.fieldOfView = Camera.main.fieldOfView;
        Shader.SetGlobalTexture("_FaceShadow", hairMaskRT);
    }
}

画头发遮罩用的Shader:

Shader "Unlit/HairMask"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
              ColorMask 0
              ZTest LEqual
             ZWrite On
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            // make fog work
            #pragma multi_compile_fog

            #include "UnityCG.cginc"

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

            struct v2f
            {
                float2 uv : TEXCOORD0;
                UNITY_FOG_COORDS(1)
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                UNITY_TRANSFER_FOG(o,o.vertex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                // sample the texture
                fixed4 col = tex2D(_MainTex, i.uv);
                // apply fog
                UNITY_APPLY_FOG(i.fogCoord, col);
                return fixed4(0,0,0,1);
            }
            ENDCG
        }

        Pass
        {
            ZTest Less 
            ZWrite Off
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            // make fog work
            #pragma multi_compile_fog

            #include "UnityCG.cginc"

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

            struct v2f
            {
                float2 uv : TEXCOORD0;
                UNITY_FOG_COORDS(1)
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                UNITY_TRANSFER_FOG(o,o.vertex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                // sample the texture
                fixed4 col = tex2D(_MainTex, i.uv);
                // apply fog
                UNITY_APPLY_FOG(i.fogCoord, col);
                return fixed4(1,1,1,1);
            }
            ENDCG
        }
    }
}

脸部Shader(额发投影部分):

half hairShadow = 1.0;
#if USE_SUPER_SHADOW
    float2 scrUV = input.scrPos.xy / input.scrPos.w;

    //计算该像素的Screen Position
    //float2 scrPos = i.positionSS.xy / i.positionSS.w;
    //获取屏幕信息
    float4 scaledScreenParams = _ScreenParams;
    //计算View Space的光照方向
    float3 viewLightDir = normalize(input.viewLightDir) * (1.0 / input.ndcW);

    //计算采样点,其中_HairShadowDistace用于控制采样距离
    float2 samplingPoint = scrUV + _HairShadowDistace * viewLightDir.xy * float2(1 / scaledScreenParams.x, 1 / scaledScreenParams.y);

    //若采样点在阴影区内,则取得的value为1,作为阴影的话还得用1 - value;
    hairShadow = tex2D(_FaceShadow, samplingPoint).r;
#endif

half4 color = lerp(diffuse , diffuse * _ShadowColor.xyz ,hairShadow);

四、基于深度屏幕等距边缘光

采用传统NOV的方式,对于法线变换比较小的平面,会出现不合理的大面积泛光,原理也好理解,因为整个面法线朝向都一致。除此之外做不到等距,所以有了基于屏幕深度的等距边缘光。等距边缘光还可以配合原来的一起来做,然后Lerp,做出更好的效果。

基本思路:摄像机渲染出深度,通过深度方向上做偏移,做出边缘轮廓效果。

相机上要设置开启深度:MainCam.depthTextureMode |= DepthTextureMode.Depth;

物体身上Shader:

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

struct v2f
{
    float2 uv : TEXCOORD0;
    float clipW :TEXCOORD1;
    float4 vertex : SV_POSITION;
    float signDir : TEXCOORD2;
};
sampler2D _CameraDepthTexture;
float4 _MainTex_ST;
float4 _Color;
float  _RimOffect;
float _Threshold;
v2f vert (appdata_full v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
    o.clipW = o.vertex.w ;
    float3 viewNormal = mul(UNITY_MATRIX_IT_MV, v.normal);
    float3 clipNormal = mul(UNITY_MATRIX_P, viewNormal);
    o.signDir = sign(-v.normal.x);
    return o;
 }
fixed4 frag (v2f i) : SV_Target
{
    float2 screenParams01 = float2(i.vertex.x/_ScreenParams.x,i.vertex.y/_ScreenParams.y);
    float2 offectSamplePos = screenParams01-float2(_RimOffect/i.clipW,0) * i.signDir;

    float offcetDepth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, offectSamplePos);
    float trueDepth   = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, screenParams01);

    float linear01EyeOffectDepth = Linear01Depth(offcetDepth);
    float linear01EyeTrueDepth = Linear01Depth(trueDepth);
    float depthDiffer = linear01EyeOffectDepth-linear01EyeTrueDepth;

    float rimIntensity = step(_Threshold,depthDiffer);
    float4 col = float4(rimIntensity,rimIntensity,rimIntensity,1);
    return col;
}
ENDCG

五、脸部伦勃朗光

卡通动漫中如果快速建立人体脸部结构采用了这个,也可以叫仿米哈游脸部阴影, 当然也有法线矫正来实现。法线矫正美术实现起来难度较大,而且特别繁琐。最终选择Lightmap图。代码思路简单,关键在于Lightmap图的制作。

代码实现:

float3 _Up    = float3(0,1,0);                          //人物上方向 用代码传进来
float3 _Front = float3(0,0,-1);                         //人物前方向 用代码传进来
float3 Left = cross(_Up,_Front);
float3 Right = -Left;
//也可以直接从模型的世界矩阵中拿取出 各个方向
//这要求模型在制作的时候得使用正确的朝向: X Y Z 分别是模型的 右 上 前
//float4 Front = mul(unity_ObjectToWorld,float4(0,0,1,0));
//float4 Right = mul(unity_ObjectToWorld,float4(1,0,0,0));
//float4 Up = mul(unity_ObjectToWorld,float4(0,1,0,0));

float FL =  dot(normalize(_Front.xz), normalize(L.xz));
float LL = dot(normalize(Left.xz), normalize(L.xz));
float RL = dot(normalize(Right.xz), normalize(L.xz));
float faceLight = faceLightMap.r + _FaceLightmpOffset ; //用来和 头发 身体的明暗过渡对齐
float faceLightRamp = (FL > 0) * min((faceLight > LL),(1 > faceLight+RL ) ) ;
float3 Diffuse = lerp( _ShadowColor*BaseColor,BaseColor,faceLightRamp);

关于Lightmap图的制作,一般有以下几个思路:

  1. 通过Pencil软件,美术绘制等高线方法绘制
    【教程】使用csp等高线填充工具制作三渲二面部阴影贴图

  2. 通过在Unity或UE4游戏引擎中,自己编写工具来生成
    雪涛:卡通脸部阴影贴图生成 渲染原理

  3. 通过外部工具,无需进入引擎自动化生成,方便好使(推荐该方法).
    橘子猫:如何快速生成混合卡通光照图

六、闪电式流动式头发高光

卡通渲染中的头发一般有三种做法。

  1. 高级一点的Kaijiaya来做出各向异性效果(劣势:不太容易控制形状,下方链接内有代码实现)。

羽扇轩轩:COS_NPR非真实渲染_头发_ABigDeal的博客-CSDN博客

  1. 利用Matcap手段实现(基于视角的,和灯光L无关,下方链接内有代码实现)。

Hugh86:Unity NPR之日式卡通渲染(基础篇)

  1. 还有米哈游最早在《崩坏3》中研究出来的流动式高光,个人喜欢这种做法。原理大概是把灯光方向投影到XY平面,在XY平面内按照Blinphong方式计算高光并结合高光Mask贴图打造天使环形状,具体实现看如下代码,二次元效果更浓厚,更容易控制,更符合画师的要求,制作出的效果。
float4 uv0 = i.uv0;
float3 L = UnityWorldSpaceLightDir(i.positionWS);
float3 V = UnityWorldSpaceViewDir(i.positionWS);
float3 H = normalize(L + V);
float3 N = normalize(i.normalWS);

float3 NV = mul(UNITY_MATRIX_V, N);//顶点normal去做
float3 HV = mul(UNITY_MATRIX_V, H);

float NdotH = dot(normalize(NV.xz), normalize(HV.xz));
NdotH = pow(NdotH, 6) * _LightWidth;//6控制高光锐利程度,可以替换为属性
NdotH = pow(NdotH, 1 / _LightLength);//_LightLength控制高光长度

float lightFeather = _LightFeather * NdotH;
float lightStepMax = saturate(1 - NdotH + lightFeather);
float lightStepMin = saturate(1 - NdotH - lightFeather);
float3 lightColor_H = smoothstep(lightStepMin, lightStepMax, clamp(lightMap.r, 0, 0.99)) * _LightColor_H.rgb;
float3 lightColor_L = smoothstep(_LightThreshold, 1, lightMap.r) * _LightColor_L.rgb;
float4 specularColor = (lightColor_H + lightColor_L) * (1 - lightMap.b) * lerp(1, _LightIntShadow, shadowStep);
return specularColo


上述代码中Lightm.r和.b通道图

七、相机Fov描边修正

网络上基本也就三种描边:在物体空间的、基于相机空间的、还有为了实现不随屏幕变大变小的NDC裁剪空间的。但基本都没有讲述Fov这个因素,在二次元卡通渲染中,很多大招镜头特效,动画人员在做动作的时候并不会改变相机的距离,而是直接改变相机的Fov,很可能从巨大的广角60度直接变为18度等等,这样上述三种描边将全部泡汤,因为并没有考虑Fov影响。

这里先标记下常规的三种空间的描边代码,假设法线数据已经经过修正,没有断边,不知道如何修正的,参考以下链接:

Jason Ma:【Job/Toon Shading Workflow】自动生成硬表面模型Outline Normal

如下代码中没有加入处理顶点色对于描边的影响,可以用顶点色控制描边粗细和ZOffset,简单不写了。

基于物体空间的(优势:由于修改的是物体空间顶点的缩放,所以有时候需要涉及到一些需要深度Depth的后期效果的时候,能够保持正确,因为深度Depth需要的是物体的世界空间位置),在这里加入方向矫正因子:

v2f o;
float3 fixedVerterxNormal = v.tangent.xyz;//这里可以写入切线空间或顶点色都行
float3 dir = normalize(v.vertex.xyz);
float3 dir2 = fixedVerterxNormal;
float D = dot(dir,dir2);
dir = dir * sign(D);
dir = dir * _Factor + dir2 * (1 - _Factor);
v.vertex.xyz += dir * _Outline*0.001;
o.pos = UnityObjectToClipPos(v.vertex);

基于相机空间的(一般卡通渲染的常规做法,可以保证物体在不等比缩放情况下依然正确):

v2f o;
float3 fixedVerterxNormal = v.tangent;
float3 viewSpaceNormal = mul(UNITY_MATRIX_IT_MV, fixedVerterxNormal);
viewSpaceNormal.z = 0.001;//拍扁,解决凹面穿帮问题产生杂色
float4 viewSpacePos = mul(UNITY_MATRIX_MV, v.vertex);
float dis2Cam = length(viewSpacePos.xyz);
float width = _OutlineWidth * dis2Cam;
viewSpacePos.xy += normalize(viewSpaceNormal).xy * width * dis2Cam;
o.pos = mul(UNITY_MATRIX_P, viewSpacePos);

基于NDC裁剪空间的(可以做到描边粗细恒定,但是相机很远的时候看上去全是黑点,一般都做Clamp改良):

v2f o;
float3 fixedVerterxNormal = v.tangent;
float4 pos = UnityObjectToClipPos(v.vertex);
float ScaleX = abs(_ScreenParams.x / _ScreenParams.y);
float3 viewNormal = mul((float3x3)UNITY_MATRIX_IT_MV, fixedVerterxNormal);
float3 ndcNormal = normalize(TransformViewToProjection(viewNormal.xyz)) * clamp(pos.w, 0, 1);//clamp(0,1)加上后当相机拉远后会减弱看上去全是黑色描边明显问题,也可以不加
float2 offset = 0.01 * _OutlineWidth * ndcNormal.xy;
offset.x /= ScaleX;//为了解决屏幕长宽比引起的上下和左右的宽度差异
pos.xy += offset;
o.vertex = pos;


效果基本差不多

接下来就开始探索让描边支持Fov的影响,首先理解以下两个知识点:

获取相机Fov:由于我们的相机是一个对称的视见体,所以投影矩阵 如下(基于OpenGL):

所以投影矩阵的第二行第二列就是Fov一半角度的tan的倒数。如果求Fov即要求arctan需要消耗不小的指令数,为了移动端性能考虑,这里直接用tan(Fov/2)来作为影响因子:float fov = 1.0 / unity_CameraProjection1.y。

获取与相机的距离:

float3 positionVS = mul(UNITY_MATRIX_MV, input.positionOS).xyz;
float viewDepth = abs(positionVS.z);

通过上述两个操作,就可以将距离和Fov因子同时考虑进去了,然后在视空间处理描边即可,本人改良后的代码:

v2f o;
float3 fixedVerterxNormal = v.tangent;
float4 viewSpacePos = mul(UNITY_MATRIX_MV, v.vertex);
float4 vert = viewPos / viewPos.w;
float s = -(viewPos.z / unity_CameraProjection[1].y);
float power = pow(s, 0.5);
float3 viewSpaceNormal = mul(UNITY_MATRIX_IT_MV, fixedVerterxNormal);
viewSpaceNormal.z = 0.01;
viewSpaceNormal = normalize(viewSpaceNormal);
float width = power*_OutlineWidth;
vert.xy += viewSpaceNormal.xy *width;
vert = mul(UNITY_MATRIX_P, vert);
o.vertex = vert;

八、透视效果矫正

我们参考《原神》的编队界面,当同屏幕出现多个角色时,处于相机外侧的角色会出现明显的变形,即便相机在Fov 40度情况下,也会很明显。我相信读者肯定想到了说要用正交投影,但是正交投影下,角色会完全失去透视关系,尤其注意角色的鞋会发现后边的跑到前边,因为透视关系丢失了,而这并不是美术想要的,美术还是希望有透视关系。说白了也就是美术希望站在外侧的角色效果也能和屏幕中间的那个一样,没有出现透视的影响。参考了如下文章。

《bluerose:在Ue4中实现对二次元模型进行透视校正》


《原神》多个角色站在一起,外侧的角色效果毫无透视造成的畸形

经过深度思考,得到了自己的一套实现思路,将透视矩阵的前两行的X和Y偏移值改为固定值,可能就是由于这两个值和Fov透视有关系,才导致的近大远小的结果,才导致外侧的角色看起来透视明显的原因。

Unity中代码实现:

half _ShiftX;//可以C#传入进去,在X方向上的偏移,由美术调节
half _ShiftY;//可以C#传入进去,在Y方向上的偏移,由美术调节

v2f vert (appdata v)
{
    v2f o;

    float4 positionVS = mul(UNITY_MATRIX_MV, v.vertex);
    float4x4 PMatrix = UNITY_MATRIX_P;
    PMatrix[0][9] = _ShiftX;
    PMatrix[1][10] = _ShiftY;
    o.pos = mul(PMatrix, positionVS);
}

九、动画摄影后期

这个概念很大,包含很多方面,这样的技术文章相对较少,找到了两篇参考,讲的相当不错,放上如下链接:

流朔:【Unity URP】一次对卡通渲染仿动画摄影的探索

公主癌菜小干:【项目分析笔记】卡通感风格的渲染方法和思考

第二篇文章中讲了两点,Flare和Parama等效果。其实上述提到的2局部Bloom效果也可以属于这个领域。除了这两个以外,文章2也提到了比较明显说明,如果你采用了Unity自带的PPSV中的ACES色调映射,会让饱和度降低,尤其对于高明度的物体,解决方式魔改PPSV Package中的ACES矫正参数,说白了相当于改变了曲线的倾斜,使之尽可能的保留饱和度。

ACES改造Unity默认Post:

关于Flare和Parama找机会再实践(其实按照上述链接应该好实现,用上Post后期自带的暗角Vignette加强画面对比,然后再加个自定义后期画张黑白遮罩图,左上到右下有个渐变模拟光感效果,效果也就差不多了)。

十、追加

这些二次元独有的效果如果在一款高质量卡通游戏中不存在,总感觉会缺少点什么。 以上的实现思路也只是个人的实现,欢迎留言讨论。以下放上各种技术对应的参考链接,阅读后理解会更加深刻。


Referrence:

  1. MinGQ1:卡通渲染描边
  2. bluerose:在Ue4中实现对二次元模型进行透视校正
  3. Cutano:Unity URP Shader 与 HLSL 自学笔记六 等宽屏幕空间边缘光
  4. 2173:【03】从零开始的卡通渲染-着色篇2
  5. 流朔:【Unity URP】以Render Feature实现卡通渲染中的刘海投影
  6. 雪涛:卡通脸部阴影贴图生成 渲染原理
  7. 公主癌菜小干:【项目分析笔记】卡通感风格的渲染方法和思考

这是侑虎科技第1172篇文章,感谢作者一滴血的记忆供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:https://www.zhihu.com/people/yidi-xie-de-ji-yi

再次感谢一滴血的记忆的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)