前言
距离上一篇关于刘海投影的文章也过了一年有余https://zhuanlan.zhihu.com/p/232450616,笔者也在这期间积累了一些工作经验,于是在实习结束后的某一天灵感迸现,发现刘海投影应有更加简单且高效的做法。
效果实现原理
以模板测试为核心,原理变得更为简单了:
在绘制面部时写入特定的模板值X,然后在不透明物体绘制完之后再绘制一次头发,此时根据屏幕空间的光照方向对它的裁剪空间坐标进行偏移,并只在模板值X时通过模板测试。
不熟悉模板测试的读者可以参考以下文章:
https://zhuanlan.zhihu.com/p/28506264
本文目录为:
- 使用Render Feature额外绘制头发
- 改良-以性能换效果
- 结语
注
笔者所用Unity版本为2019.4.6f1,URP 7.3.1
笔者经验甚少,才浅学疏,难以避免文中出现错误,还请大家不吝斧正,只求轻喷。
使用Render Feature额外绘制头发
我们要先在画脸的时候写入Stencil Buffer,因此角色Shader中需添加:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24 Properties
   {
   	//......
       
   	[Header(Stencil)]
       _StencilRef ("_StencilRef", Range(0, 255)) = 0
       [Enum(UnityEngine.Rendering.CompareFunction)]_StencilComp ("_StencilComp", Float) = 0
   }
   
//......
	Pass
       {
           Name "BaseCel"
           Tags { "LightMode" = "UniversalForward" }
           Stencil
           {
               Ref [_StencilRef]
               Comp [_StencilComp]
               Pass replace
           }
           
           //......
然后面部材质球面板中如此设置,意为面部必然通过模板测试,且会将模板值改为128
128这数字是笔者随便挑的,没什么特殊含义。
之后便添加一个RenderFeature,专门用于绘制头发
关于一些细节的基础内容笔者已在上一篇文章中阐述过,这次就直接贴代码,不过多解释了1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
public class CelHairShadow_Stencil : ScriptableRendererFeature
{
    [System.Serializable]
    public class Setting
    {
        public Color hairShadowColor;
        [Range(0, 0.1f)]
        public float offset = 0.02f;
        [Range(0, 255)]
        public int stencilReference = 1;
        public CompareFunction stencilComparison;
        public RenderPassEvent passEvent = RenderPassEvent.BeforeRenderingTransparents;
        public LayerMask hairLayer;
        [Range(1000, 5000)]
        public int queueMin = 2000;
        [Range(1000, 5000)]
        public int queueMax = 3000;
        public Material material;
    }
    public Setting setting = new Setting();
    class CustomRenderPass : ScriptableRenderPass
    {
        public ShaderTagId shaderTag = new ShaderTagId("UniversalForward");
        public Setting setting;
        FilteringSettings filtering;
        public CustomRenderPass(Setting setting)
        {
            this.setting = setting;
            RenderQueueRange queue = new RenderQueueRange();
            queue.lowerBound = Mathf.Min(setting.queueMax, setting.queueMin);
            queue.upperBound = Mathf.Max(setting.queueMax, setting.queueMin);
            filtering = new FilteringSettings(queue, setting.hairLayer);
        }
        public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor)
        {
            setting.material.SetColor("_Color", setting.hairShadowColor);
            setting.material.SetInt("_StencilRef", setting.stencilReference);
            setting.material.SetInt("_StencilComp", (int)setting.stencilComparison);
            setting.material.SetFloat("_Offset", setting.offset);
        }
        public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
        {
            var draw = CreateDrawingSettings(shaderTag, ref renderingData, renderingData.cameraData.defaultOpaqueSortFlags);
            draw.overrideMaterial = setting.material;
            draw.overrideMaterialPassIndex = 0;
			//获取主光源方向,并转换到相机空间
            var visibleLight = renderingData.cullResults.visibleLights[0];
            Matrix4x4 worldToScreen = renderingData.cameraData.camera.worldToCameraMatrix;
            Vector2 lightDirSS = renderingData.cameraData.camera.worldToCameraMatrix * (visibleLight.localToWorldMatrix.GetColumn(2));
            setting.material.SetVector("_LightDirSS", lightDirSS);
            CommandBuffer cmd = CommandBufferPool.Get("DrawHairShadow");
            context.ExecuteCommandBuffer(cmd);
            context.DrawRenderers(renderingData.cullResults, ref draw, ref filtering);
        }
        public override void FrameCleanup(CommandBuffer cmd)
        {
        }
    }
    CustomRenderPass m_ScriptablePass;
    public override void Create()
    {
        m_ScriptablePass = new CustomRenderPass(setting);
        m_ScriptablePass.renderPassEvent = setting.passEvent;
    }
    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        if (setting.material != null)
            renderer.EnqueuePass(m_ScriptablePass);
    }
}
在外面的面板中如此设置即可,当然头发还是得设置一下Layer为Hair
之后便是指定Material,使用这个Shader即可,可见内容相当简单1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84Shader "Custom/HairShadow"
{
    Properties
    {
        _Color ("Color", Color) = (1, 1, 1, 1)
        _Offset ("Offset", float) = 0.02
        [Header(Stencil)]
        _StencilRef ("_StencilRef", Range(0, 255)) = 0
        [Enum(UnityEngine.Rendering.CompareFunction)]_StencilComp ("_StencilComp", float) = 0
    }
    SubShader
    {
        Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalRenderPipeline" }
        
        HLSLINCLUDE
        
        
        CBUFFER_START(UnityPerMaterial)
        float4 _Color;
        float _Offset;
        float4 _LightDirSS;
        CBUFFER_END
        ENDHLSL
        Pass
        {
            Name "HairShadow"
            Tags { "LightMode" = "UniversalForward" }
            
            Stencil
            {
                Ref [_StencilRef]
                Comp [_StencilComp]
                Pass keep
            }
            ZTest LEqual
            ZWrite Off
            
            HLSLPROGRAM
            
            
            
            struct a2v
            {
                float4 positionOS: POSITION;
                float4 color: COLOR;
            };
            
            struct v2f
            {
                float4 positionCS: SV_POSITION;
                float4 color: COLOR;
            };
            
            
            v2f vert(a2v v)
            {
                v2f o;
                
                VertexPositionInputs positionInputs = GetVertexPositionInputs(v.positionOS.xyz);
                o.positionCS = positionInputs.positionCS;
                float2 lightOffset = normalize(_LightDirSS.xy);
                //乘以_ProjectionParams.x是考虑裁剪空间y轴是否因为DX与OpenGL的差异而被翻转
                //参照https://docs.unity3d.com/Manual/SL-PlatformDifferences.html
                //"Similar to Texture coordinates, the clip space coordinates differ between Direct3D-like and OpenGL-like platforms"
                lightOffset.y = lightOffset.y * _ProjectionParams.x;
                o.positionCS.xy += lightOffset * _Offset;
                o.color = v.color;
                return o;
            }
            
            half4 frag(v2f i): SV_Target
            {
                return _Color;
            }
            ENDHLSL
        }
    }
}
于是我们就可以获得一个面部有刘海投影的效果了


由于自带的深度测试,直接避免了上一篇文章中的许多问题。
这样做的优缺点也比较显然:
优点
- 规避了额外绘制Buffer,无需切换RT
- 精度与使用RT写入深度进行深度判断相比更加高
缺点
- “阴影”的绘制与光照着色及人物贴图完全无关,导致在许多情况下会显得突兀
那么笔者也截几个图给大家看看这个缺点比较明显的时候

说白了就是因为刘海投影只使用了一个暗色,而面部是用面部贴图乘以暗部颜色的,只要面部贴图不是赛璐璐风格的纯色,便无法避免两者在结果上的差异。
改良-以性能换效果
问题不就是出在咱们没有本来的贴图颜色吗,那大不了再画一次脸,这次直接就是贴图色乘以暗色,总没问题了吧。
那么我们将头发的Pass修改一下,使用ColorMask 0让它不再画入颜色,且将模板值重置为01
2
3
4
5
6
7
8
9
10Stencil
{
    Ref [_StencilRef]
    Comp [_StencilComp]
    Pass Zero
}
ZTest LEqual
ZWrite Off
ColorMask 0
于是我们给这个Shader再添加一个Pass,用于重新渲染面部:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57Pass
{
    Name "HairShadow_Face"
    Tags { "LightMode" = "UniversalForward" }
    
    Stencil
    {
        Ref 0
        Comp [_StencilComp]
        Pass keep
    }
    ZTest LEqual
    ZWrite Off
    
    HLSLPROGRAM
    
    
    
    struct a2v
    {
        float4 positionOS: POSITION;
        float4 color: COLOR;
        float2 uv: TEXCOORD;
    };
    
    struct v2f
    {
        float4 positionCS: SV_POSITION;
        float4 color: COLOR;
        float2 uv: TEXCOORD0;
    };
    
    
    v2f vert(a2v v)
    {
        v2f o;
        
        VertexPositionInputs positionInputs = GetVertexPositionInputs(v.positionOS.xyz);
        o.positionCS = positionInputs.positionCS;
        o.color = v.color;
        o.uv = v.uv;
        return o;
    }
    
    TEXTURE2D(_FaceTex);
    SAMPLER(sampler_FaceTex);
    half4 frag(v2f i): SV_Target
    {
        return SAMPLE_TEXTURE2D(_FaceTex, sampler_FaceTex, i.uv) * _Color;
    }
    ENDHLSL
}
然后在RenderFeature中增加面部贴图的指定,以及面部的再绘制:
| 1 | public class Setting | 
于是现在,我们终于能获得一个相对理想的结果了
当然,这样结果比较理想也只是因为面部的渲染算法比较简单,如果还有边缘光或者其他什么操作,大概就得真的重新用角色材质重新画一次了。
那么最后放个效果视频
结语
模板测试的存在感比较低,大家似乎都不易想到用它来实现这个Trick,而之前需要额外绘制RT的方法如今优化后应当也相对容易落地了些。
希望本文能够给在卡通渲染领域耕耘的人们带来些许启发。
参考资料
俊虎:Unity ShaderLab 模板缓存(Stencil Buffer) 基本概念
Writing shaders for different graphics APIs
感谢您阅读完本文,若您认为本文对您有所帮助,可以将其分享给其他人;若您发现文章对您的利益产生侵害,请联系作者进行删除。