永远相信美好的事情即将发生 😊!

卡通渲染之描边技术的实现(URP)

分享 Mavis 49℃ 0评论

过年后一直没更新博客主要就是跑去学卡通渲染了

因为做的时候没有记录……现在还是得总结一下了

先来放一下使用内置的Lit 和我的shader渲染出来的效果对比图

好了,接下来就一步一步实现它吧,先从描边开始吧(描边的内容unity shader 入门精要里面已经写得很详细了,这里唯一有点不同的就是使用的是URP渲染管线)

先整一个最简单的只有basemap的shader(因为资源是免费的,自己也不是专业画画的,所以素材资源不是很好,不过也够用了,似乎按照当前结合pbr的做法,basemap一般就是只有纯色,不应该出现跟光源有关的信息,我的这张图衣服上有点阴影)

Shader "ToonLit/Outline"
{
    Properties
    {
        _BaseMap ("BaseMap", 2D) = "white" {}
    }
    SubShader
    {
        HLSLINCLUDE
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            CBUFFER_START(UnityPerMaterial)
            //Variables
            CBUFFER_END
            TEXTURE2D(_BaseMap);                 SAMPLER(sampler_BaseMap);
            struct Attributes{
                float4 positionOS : POSITION;
                float4 texcoord : TEXCOORD;
            };
            struct Varyings{
            float4 positionCS : SV_POSITION;
            float2 uv : TEXCOORD1;
            };
        ENDHLSL
        Pass
        {
            Tags{"LightMode" = "UniversalForward"}        
			HLSLPROGRAM
			#pragma target 3.0
            #pragma vertex Vertex
            #pragma fragment Frag
            Varyings Vertex(Attributes input)
            {
                Varyings output;
                VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
                output.positionCS = vertexInput.positionCS;
                output.uv = input.texcoord.xy;
                return output;
            }
            float4 Frag(Varyings input):SV_Target
            {
                float4 tex = SAMPLE_TEXTURE2D(_BaseMap,sampler_BaseMap,input.uv);
                return tex;
            }
            ENDHLSL
        }
    }
    Fallback "Universal Render Pipeline/Lit"
}

这样物体就有了一个基础的样子

在进行描边前还是先把透明度测试写一下,因为脸部用了比较多的透明材质

透明度测试很简单,网上教程很多,本博客前面的学习笔记中也有,所以就不多讲了,原理就是利用basemap的alpha通道,在一个给定的阈值下去判断是否要discard该片元,从而达到透明的效果(与真正的透明是不同的,真正的透明是使用blend的混合因子去混合缓存中现有的值和我们计算出来的值)

加上透明度测试的代码如下:

Shader "ToonLit/Outline"
{
    Properties
    {
        _BaseMap ("BaseMap", 2D) = "white" {}
        [Toggle]_ENABLE_ALPHA_TEST("Enable AlphaTest",float)=0
        _Cutoff("Cutoff", Range(0,1)) = 0.5
    }
    SubShader
    {
        HLSLINCLUDE
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            #pragma shader_feature _ENABLE_ALPHA_TEST_ON
            CBUFFER_START(UnityPerMaterial)
            //Variables
            float _Cutoff;
            CBUFFER_END
            TEXTURE2D(_BaseMap);                 SAMPLER(sampler_BaseMap);
            struct Attributes{
                float4 positionOS : POSITION;
                float4 texcoord : TEXCOORD;
            };
            struct Varyings{
            float4 positionCS : SV_POSITION;
            float2 uv : TEXCOORD1;
            };
        ENDHLSL
        Pass
        {
            Tags{"LightMode" = "UniversalForward"}        
			HLSLPROGRAM
			#pragma target 3.0
            #pragma vertex Vertex
            #pragma fragment Frag
            Varyings Vertex(Attributes input)
            {
                Varyings output;
                VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
                output.positionCS = vertexInput.positionCS;
                output.uv = input.texcoord.xy;
                return output;
            }
            float4 Frag(Varyings input):SV_Target
            {
                float4 BaseMap = SAMPLE_TEXTURE2D(_BaseMap,sampler_BaseMap,input.uv);
                #if _ENABLE_ALPHA_TEST_ON
                    clip(BaseMap.a-_Cutoff);
                #endif
                return BaseMap;
            }
            ENDHLSL
        }
    }
    Fallback "Universal Render Pipeline/Lit"
}

目前的效果:

接下来写描边,卡通的描边一般比较干净利落,所以使用法线外扩是个不错的选择,屏幕后处理虽然我也实现了,但是效果放到这个人物上并不是很好,我想效果好就可以啦

描边的部分我根据大佬的文章进行了一定的改善(https://zhuanlan.zhihu.com/p/109101851)

下面是完整的代码,基本都是按照上面提到的这篇文章里写的改的

Shader "ToonLit/Outline"
{
    Properties
    {
        _BaseMap ("BaseMap", 2D) = "white" {}
        [Toggle]_ENABLE_ALPHA_TEST("Enable AlphaTest",float)=0
        _Cutoff("Cutoff", Range(0,1)) = 0.5
        _OutlineWidth("OutlineWidth", Range(0, 10)) = 0.4
        _OutlineColor("Outline Color", Color) = (0, 0, 0, 1)
        [Toggle]_OLWVWD("OutlineWidth Varies With Distance?", float) = 0
    }
    SubShader
    {
        HLSLINCLUDE
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            #pragma shader_feature _ENABLE_ALPHA_TEST_ON
            #pragma shader_feature _OLWVWD_ON
            CBUFFER_START(UnityPerMaterial)
                float _Cutoff;
                float _OutlineWidth;
                float4 _OutlineColor;
            CBUFFER_END
            TEXTURE2D(_BaseMap);                 SAMPLER(sampler_BaseMap);
            struct Attributes{
                float4 positionOS : POSITION;
                float4 normalOS : NORMAL;
                float4 texcoord : TEXCOORD;
            };
            struct Varyings{
            float4 positionCS : SV_POSITION;
            float2 uv : TEXCOORD1;
            };
        ENDHLSL
        Pass 
        {
            Tags{"LightMode" = "UniversalForward"} 
            Cull off
			HLSLPROGRAM
			#pragma target 3.0
            #pragma vertex Vertex
            #pragma fragment Frag
            Varyings Vertex(Attributes input)
            {
                Varyings output;
                VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
                output.positionCS = vertexInput.positionCS;
                output.uv = input.texcoord.xy;
                return output;
            }
            float4 Frag(Varyings input):SV_Target
            {
                float4 BaseMap = SAMPLE_TEXTURE2D(_BaseMap,sampler_BaseMap,input.uv);
                #if _ENABLE_ALPHA_TEST_ON
                    clip(BaseMap.a-_Cutoff);
                #endif
                return BaseMap;
            }
            ENDHLSL
        }
   Pass {
      Name "OutLine"
			Tags{ "LightMode" = "SRPDefaultUnlit" }
			Cull front
			HLSLPROGRAM
			#pragma vertex vert  
			#pragma fragment frag
			Varyings vert(Attributes input) {
                float4 scaledScreenParams = GetScaledScreenParams();
                float ScaleX = abs(scaledScreenParams.x / scaledScreenParams.y);//求得X因屏幕比例缩放的倍数
				Varyings output;
				VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
                VertexNormalInputs normalInput = GetVertexNormalInputs(input.normalOS);
                float3 normalCS = TransformWorldToHClipDir(normalInput.normalWS);//法线转换到裁剪空间
                float2 extendDis = normalize(normalCS.xy) *(_OutlineWidth*0.01);//根据法线和线宽计算偏移量
                extendDis.x /=ScaleX ;//由于屏幕比例可能不是1:1,所以偏移量会被拉伸显示,根据屏幕比例把x进行修正
                output.positionCS = vertexInput.positionCS;
                #if _OLWVWD_ON
                    //屏幕下描边宽度会变
                    output.positionCS.xy +=extendDis;
                #else
                    //屏幕下描边宽度不变,则需要顶点偏移的距离在NDC坐标下为固定值
                    //因为后续会转换成NDC坐标,会除w进行缩放,所以先乘一个w,那么该偏移的距离就不会在NDC下有变换
                    output.positionCS.xy += extendDis * output.positionCS.w ;
                #endif
				return output;
			}
			float4 frag(Varyings input) : SV_Target {
				return float4(_OutlineColor.rgb, 1);
			}
		    ENDHLSL
		}
    }
    Fallback "Universal Render Pipeline/Lit"
}

看一下对比图

比较重要的部分都在代码中注释了一下了,原理和解释都在那篇大佬的文章里有了,这里就不多赘述了

其中因为同一个地方有几个法线导致的描边撕裂,用到了平滑法线的方法,写一个脚本修改Mesh的法线属性,不过这个只能在运行中有效果,mesh的属性是不会被彻底修改的,只是动态覆盖了而已。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SmoothNormal : MonoBehaviour
{
    Mesh MeshNormalAverage(Mesh mesh)
    {
        Dictionary<Vector3, List<int>> map = new Dictionary<Vector3, List<int>>();
        for (int v = 0; v < mesh.vertexCount; ++v)
        {
            if (!map.ContainsKey(mesh.vertices[v]))
            {
                map.Add(mesh.vertices[v], new List<int>());
            }
            map[mesh.vertices[v]].Add(v);
        }
        Vector3[] normals = mesh.normals;
        Vector3 normal;
        foreach(var p in map)
        {
            normal = Vector3.zero;
            foreach (var n in p.Value)
            {
                normal += mesh.normals[n];
            }
            normal /= p.Value.Count;
            foreach (var n in p.Value)
            {
                normals[n] = normal;
            }
        }
        mesh.normals = normals;
        return mesh;
    }
    void Awake()
    {
        if (GetComponent<MeshFilter>())
        {
            Mesh tempMesh = (Mesh)Instantiate(GetComponent<MeshFilter>().sharedMesh);
            tempMesh=MeshNormalAverage(tempMesh);
            gameObject.GetComponent<MeshFilter>().sharedMesh = tempMesh;
        }
        if (GetComponent<SkinnedMeshRenderer>())
        {
            Mesh tempMesh = (Mesh)Instantiate(GetComponent<SkinnedMeshRenderer>().sharedMesh);
            tempMesh = MeshNormalAverage(tempMesh);
            gameObject.GetComponent<SkinnedMeshRenderer>().sharedMesh = tempMesh;
        }
    }
}

代码比较好理解,也就不多解释了

看一下效果对比吧

好了,晚了晚了,都22点了,锻炼一下洗个澡澡,其他的技术就等明天有时间再继续说吧。

———————————————————————————————

又到了新的一天,再来总结一下描边的相关技术吧

普通的法线外扩会出现下面这个问题:

 

明显看到描边会随着相机的靠近而增加,这是因为不论法线外扩在世界空间中进行还是观察空间中进行,计算出来的偏移量都是在世界空间下绝对的长度,也就是正常的效果(近大远小),不会因观察或物体的移动而导致宽度变化。

而这样的描边效果就会导致给特写镜头的时候描边效果太强烈。所以需要在一个新的空间里计算偏移量,毕竟空间也不多,排除了两个,也就剩下两个常用的了,一个是裁剪空间,一个是转成NDC(Normalized Device Coordinate),当然在转成NDC后操作是最好的,因为这个时候所有物体都被放缩到一个标准化的坐标下,让偏移量在这个空间内成为一个固定值,就不会导致上面的情况出现,而转NDC的操作不是我们控制的,那就只剩裁剪空间了,虽然不能控制NDC,但转成NDC是非常简单的一步,只要先进行一次转NDC的逆运算,那么就算法线外扩的计算在裁剪空间进行,在NDC也是不会变的了。总体来说在裁剪空间进行法线的外扩,然后这个时候计算出来的偏移量再乘w(因为后续转到NDC坐标会/w)。

对应代码如下,我还用了一个开关_OLWVWD_ON去确定是否开启这个操作:

Varyings output;
VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
VertexNormalInputs normalInput = GetVertexNormalInputs(input.normalOS);
float3 normalCS = TransformWorldToHClipDir(normalInput.normalWS);//法线转换到裁剪空间
float2 extendDis = normalize(normalCS.xy) *(_OutlineWidth*0.01);//根据法线和线宽计算偏移量
output.positionCS = vertexInput.positionCS;
#if _OLWVWD_ON
    //屏幕下描边宽度会变
    output.positionCS.xy +=extendDis;
#else
    //屏幕下描边宽度不变,则需要顶点偏移的距离在NDC坐标下为固定值
    //因为后续会转换成NDC坐标,会除w进行缩放,所以先乘一个w,那么该偏移的距离就不会在NDC下有变换
    output.positionCS.xy += extendDis * output.positionCS.w ;
#endif

具体实现的效果:

 

其次还有一个问题就是屏幕比例导致的法线外扩距离被拉伸,看起来不均匀,因为ndc是长宽高相同的一个正方体,那么要显示在一个长宽比不为1的界面上,就肯定会被拉伸,这个的解决操作就比较简单了,也是和上面NDC那块思路一样,被拉伸也是我们不能控制的,被拉伸=被乘了一个系数,那么就是要做这个操作的逆运算,来让该操作对于描边宽度来说没效果就好了,现在搞清楚一下这个操作具体怎么做的然后来个逆运算。

其实就是个相似关系……看看宽度更长,所以红圈部分x值更长(不只是红圈部分被拉伸,只是红圈部分明显)

1:1的内容会被放到scaledScreenParams.x :scaledScreenParams.y的屏幕上,我们希望还是1:1,只需要对某一个坐标进行操作就好了,例如,我们针对x坐标进行修改,x在NDC中会被乘以scaledScreenParams.x /scaledScreenParams.y这个比例来拉伸,那么我们要做的操作就是除以这个比例

 

具体代码如下:

                float4 scaledScreenParams = GetScaledScreenParams();
                float ScaleX = abs(scaledScreenParams.x / scaledScreenParams.y);//求得X因屏幕比例缩放的倍数
				        Varyings output;
				        VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
                VertexNormalInputs normalInput = GetVertexNormalInputs(input.normalOS);
                float3 normalCS = TransformWorldToHClipDir(normalInput.normalWS);//法线转换到裁剪空间
                float2 extendDis = normalize(normalCS.xy) *(_OutlineWidth*0.01);//根据法线和线宽计算偏移量
                extendDis.x /=ScaleX ;//由于屏幕比例可能不是1:1,所以偏移量会被拉伸显示,根据屏幕比例把x进行修正

修正后的对比如下:

好了,描边的基本操作就到这里,我写的描边部分还另外增加了对顶点色的支持,根据选择的顶点色的通道来对描边宽度进行缩放,可以用来表现手绘感,之前跟着国外大佬的视频写了个顶点着色器,因为只是学习用用,这个着色器对网格顶点的数据也只是做覆盖,不过是在编辑器没有关闭的情况下会一直覆盖,关掉就没了,以后有机会写个保存的。

先刷一下顶点色,我刷在了R通道:

然后在描边相关设置里,我选择受顶点色的r通道的影响。

当然这只是我写来玩玩儿的……毕竟正规一点还是得美术来干这事,从贴图里拿数据才是真理^_^

好了,基本有关于法线外扩这种方法的描边内容就到这里,我要继续写下一篇了,下一篇就讲讲边缘光

等等

突然想起来之前想到的一个问题,虽然转到NDC会解决描边在世界空间下不变的问题,但我觉得也会增加另一个问题,就是在ndc下人物会变小,但如果描边宽度不变,就会出现一样的异常,所以其实只是把问题转移到了另一个空间,所以最好的办法应该是测定下深度,在给定阈值内使用NDC下的计算,超过了就还是应该使用世界或观察空间下的计算

先来看看使用NDC时,这个问题出现的效果图

不使用NDC时,人物就会干净一些:

那加上一个阈值来搞一个自动调整,本来打算用深度值进行判断使用哪种描边,,,但效果有点问题,,退而求其次,,因为用NDC的时候,特写镜头会好看一些,而变小后不好看,,,那么就干脆太远了就不描边了……好吧,这样效果也很奇怪,,先留个坑吧,以后想起来了会解决了再来搞搞

4.2号来更新

根据知乎评论区大佬的提醒,现在可以修复上面的那个问题了

加上这么一句话就可以啦

float ctrl = clamp(1/output.positionCS.w,0,1)
output.positionCS.xy += extendDis * output.positionCS.w * ctrl;

效果如下:

真棒,感谢大佬

 

 


Mavis , 版权所有丨如未注明 , 均为原创丨本网站采用BY-NC-SA协议进行授权
转载请注明原文链接:卡通渲染之描边技术的实现(URP)
喜欢 (1)
发表我的评论
取消评论

表情

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址