过年后一直没更新博客主要就是跑去学卡通渲染了
因为做的时候没有记录……现在还是得总结一下了
先来放一下使用内置的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;
效果如下:
真棒,感谢大佬