作者:王子饼干 ——镇江虹视游戏科技有限公司联合创始人、《多洛可小镇》制作人
原文链接:UnityShader入门精要笔记15:高级纹理B
原文发布时间:2020年3月22日
文章类型: 授权转载
什么是恶?凡是源于虚弱的东西都是恶。——F·W·尼采【反基督】
笔记当前使用的Unity版本:“2019.3.3”
笔记当前Unity最新发行版:“2019.3.6”
笔记当前Unity最新的预览版本:“2020.1.0.Alpha 25”
上节涉及到了立方体纹理,讲了天空盒,反射与折射的三个基本概念,不过只是简单实现,它们还不足以真正的参与到实际的游戏的开发当中。
在本节当中,我们会补充一个新的知识点,叫做“菲尼尔反射(Fresnel Reflection)”,然后进入一个新的部分,叫做“渲染纹理”(RenderTexture)
菲涅尔反射是一种常见的光学现象,简单来说,菲涅尔反射就是当光线照射到物体表面时,一部分发生了反射或者折射,一部分进入了物体内部。这两部分之间存在一定的比率关系,这个比率和你观察物体表面的角度有关。它可以通过菲涅尔反射公式来进行计算。
一个典型的案例是,当你站在湖边的时候,望着脚底的水面,发现水面几乎是透明的,你可以看见水底的鱼和石子,然而当你望向远处,基本上已经不能在看见水底的情形,只能看见水的表面的反射,这就是菲涅尔反射。如下图所示:
真实世界的菲涅尔反射是非常复杂的,(其实只要是真实世界里的,不论是啥,都很复杂),这里提供的公式也是一种近似的公式
2.1.1 Schlick菲涅尔反射公式
这里的F0是一个反射系数,用于控制菲涅尔反射的强度,v是视角方向,n是表面法线。
2.1.2 Empricial菲涅尔反射公式
其中bias,scale,power是控制项。
我们通过Schlick菲涅尔反射公式来实践一下,因为这个公式相对来说简单一点。下面是完整的源代码。
Shader "UShaderMagicBook/FresnelReflection_schlick"{
Properties{
_BaseColor("Base Color",Color) = (1.0,1.0,1.0,1.0)
_FresnelScale("Fresnel Scale",Range(0,1)) = 0.5
_Cubemap("Cubemap",Cube) = "skybox"{}
}
SubShader{
pass{
Tags{"LightMode"="ForwardBase"}
CGPROGRAM
#pragma vertex Vertex
#pragma fragment Pixel
#include "Lighting.cginc"
#include "AutoLight.cginc"
struct vertexInput{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct vertexOutput{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
float3 worldViewDir : TEXCOORD2;
float3 worldReflectDir : TEXCOORD3;
SHADOW_COORDS(4)
};
fixed4 _BaseColor;
fixed _FresnelScale;
samplerCUBE _Cubemap;
vertexOutput Vertex(vertexInput v){
vertexOutput o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld,v.vertex);
o.worldViewDir = UnityWorldSpaceViewDir(o.worldPos);
o.worldReflectDir = reflect(-o.worldViewDir,o.worldNormal);
TRANSFER_SHADOW(o)
return o;
}
fixed4 Pixel(vertexOutput i):SV_TARGET{
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 lightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 worldViewDir = normalize(i.worldViewDir);
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * _BaseColor.xyz;
fixed3 diffuse = _LightColor0.xyz * _BaseColor.xyz * saturate(dot(worldNormal,lightDir));
float fresnel = _FresnelScale + (1 - _FresnelScale) * pow(1 - dot(worldNormal,worldViewDir),5);
//schlick菲涅尔反射计算公式
fixed3 reflection = texCUBE(_Cubemap,i.worldReflectDir).xyz;
UNITY_LIGHT_ATTENUATION(atten,i,i.worldPos);
fixed3 color = ambient + lerp(diffuse,reflection,saturate(fresnel)) * atten;
//注意我们这里是把菲涅尔反射系数作为线性插值的权重在物体原本的漫反射颜色和反射颜色之间进行过度
return fixed4(color,1.0);
}
ENDCG
}
}
}
最终效果如下:
不过由于胶囊体本身的边缘是圆的,所以效果不是很明显,但是我们还是可以发现,当我们的视角与物体表面垂直的时候,看到的几乎是物体原本的颜色,而不是反射的颜色。
另外要注意,和折射与反射不同的是,我们计算出来的菲涅尔系数是要作为在漫反射颜色和反射之间的一个线性插值权重。关于什么是插值函数,可以参考我的游戏开发技术杂谈2:
王子饼干:游戏开发技术杂谈2:理解插值函数lerp111 赞同 · 6 评论文章
摄像机其实是一个非常特殊的对象,它本身就是一种捕捉画面的东西,在渲染中也是这样,有的时候我们需要进行一些实时的渲染技术,就需要用到摄像机。现代化的GPU允许我们把三维场景渲染到一个中间缓冲中,这个缓冲叫做“渲染目标纹理(Render Target Texture)RTT”,与之相对的是“多重渲染目标(Multi Render Target)MRT”,这种技术是指GPU允许我们把场景同时渲染到多个目标当中,而不再为每个渲染目标纹理设置单独的相机。延迟渲染就是MRT的一个应用。
我们的摄像机所捕捉到的画面可以不用显示到屏幕上,也可以作为一个材质的纹理输入。我们可以创建一个RenderTexture,把它作为摄像机的渲染目标,设置完成后,相机之后所拍摄到的画面就可以更新到RenderTexture中,这样一来,我们就可以通过这个纹理来实现一些有用的东西,比如水面倒影,镜面效果等。
镜面效果是渲染纹理的最常见的应用,在实现它之前,我们先做一些准备工作,让场景里的内容稍微丰富一些。
创建一个RenderTexture,这个可以在Project视图当中,右击->Create->RenderTexture来创建,接着,我们在平面上创建一个新的相机。它的方向朝上。
适当的调整间距,然后把这个摄像机的RenderTarget,渲染目标,设置为我们刚才创建的RenderTexture。
接下来编写代码,这可能是我们遇见过的最简单的Shader代码了。
Shader "UShaderMagicBook/Mirror"{
Properties{
_MainTex("Main Texture",2D) = "white"{}
}
SubShader{
pass{
Tags{"LightMode"="Always"}
CGPROGRAM
#pragma vertex Vertex
#pragma fragment Pixel
#include "UnityCG.cginc"
struct vertexOutput{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
};
sampler2D _MainTex;
vertexOutput Vertex(appdata_base v){
vertexOutput o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
return o;
}
fixed4 Pixel(vertexOutput i):SV_TARGET{
return tex2D(_MainTex,i.uv);
}
ENDCG
}
}
}
额外讲一下什么是appdata_base,其实大家在编写Shader的时候已经发现了,vertexInput这个结构体基本上是差不多的,无非就是那几类输入,因为这个输入结构体过于固定,所以Unity定义了4种基础的顶点着色器输入结构体,其中之一就是appdata_base,咱们可以去UnityCG.cginc中稍微搂一眼它们的定义:
呼,一目了然。好了,我们来看一下镜面的最终效果。
emmm,它好像反掉了。。。
不知道为什么反了,可能是摄像机的毛病?不过也没有关系,我们可以在Shader当中把采样纹理反相。
还不是特别的好,看起来我们应该使用正交相机,而不是透视相机。
好像有点感觉了,大概镜面就是这个意思吧。由于这个纹理是通过摄像机实时拍摄到的,所以当画面上的对象产生变化的时候,这个镜面也可以实时的更新画面。
除了把摄像机的RenderTarget设置为RenderTexture来获取摄像机拍摄的画面,我们还可以通过GrabPass来捕捉画面。
Pass是什么?简单来说,Pass就是一个根据各种输入来计算输出的黑盒,而这个黑盒是我们自己定义的。而输出就是模型表面的颜色,纹理之类的。我们之前还见识过ShadowCaster,用一个Pass来计算阴影映射纹理。
但是呢,GrabPass稍微有点不同,这个不同体现在功能性上,即它用于捕捉当前的画面存储到一张纹理中。当我们在Shader当中定义了一个GrabPass之后,Unity会把当前的屏幕图像绘制到这个pass当中。
GrabPass通常是用来进行透射效果的实现的,与简单的透明度混合不同,使用GrabPass可以让我们更加细节的控制模型后面的图像,比如使用法线来模拟折射。(透过玻璃球看后面的纹理会发生折射,然而透明度混合无法进行折射)
另外要注意一点,使用GrabPass的时候,我们需要小心渲染队列的设置,GrabPass一般是用于透明的物体。所以渲染队列要设置为Transparent。
好的,接下来让我们通过代码来实践一下玻璃效果。玻璃效果就难了,我们这里分段来解释一下各部分代码的作用。
Properties{
_MainTex("Main Texture",2D) = "white"{}
_BumpTex("Normal Map",2D) = "bump"{}
_CubeMap("Cubemap",Cube) = "skybox"{}
_Distortion("Distortion",Range(0,100)) = 10
_RefractAmount("Refract Amount",Range(0,1)) = 1.0
//其中_MainTex是玻璃的主纹理,_BumpTex是玻璃的法线纹理
//Cubemap是玻璃的反射纹理,而_Distortion是变形系数
//_RefractAmount是折射系数
}
输入就是这么多啦,输入一共用到了三个纹理,分别是玻璃的主纹理,玻璃的法线纹理,玻璃的反射纹理。另外增加了两个参数,一个是_Distortion,用于控制法线的凹凸程度,你可以将它命名为_BumpScale更为直观,最后_RefractAmount我们已经见过了,它就是折射系数。用于控制折射的强度,简单来说,我们要用_RefractAmount作为权重在主纹理和反射纹理之间进行插值,以得到一个平滑的过度。
SubShader{
Tags{"Queue"="Transparent" "RenderType"="Opaque"}
/*这里的Queue设置为Transparent和RenderType设置为Opaque*/
GrabPass{"_RefractionTex"}
//GrabPass会把模型后面的内容捕捉,塞进一张纹理中。我们只需要指定纹理的变量名即可
pass{
//other code
}
}
GrabPass就是抓取屏幕内容到一张纹理中,它有两种实现的形式
第一种会把屏幕内容抓取到一个叫做_GrabTexture中的纹理中,恐怖的是_GrabTexture被引用了多少次,它就会抓取多少次,是一种非常耗费性能的做法,目前出现这种做法的理由不明。
第二种会把屏幕内容抓取到我们指定的纹理变量中,无论我们调用多少次,它都抓取一次。
不过另外一个特别重要的问题就是,书上说是抓取屏幕内容,有人说是抓取模型所在位置的屏幕内容,又有人说是抓取模型后的内容,那么到底是抓取哪呢?
为了搞清楚这个问题,我新建了一个四边面,然后我们尝试构建一个GrabPass,把抓取到的内容不加修饰的直接绘制到四边面上。
完整的代码比较简单,如下所示:
Shader "UShaderMagicBook/GrabPassTest"{
Properties{
// no input
}
SubShader{
Tags{"Queue"="Transparent" "RenderType"="Opaque"}
GrabPass{"_GrabPassTexture"}
pass{
Tags{"LightMode"="Always"}
//不论什么情况下,这个pass总会被绘制
CGPROGRAM
#pragma vertex Vertex
#pragma fragment Pixel
#include "UnityCG.cginc"
sampler2D _GrabPassTexture;
struct vertexOutput{
float4 pos : SV_POSITION;
float2 uv :TEXCOORD0;
};
vertexOutput Vertex(appdata_base v){
vertexOutput o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
return o;
}
fixed4 Pixel(vertexOutput i):SV_TARGET{
return tex2D(_GrabPassTexture,i.uv);
}
ENDCG
}
}
}
我们的得到的结果,如下图所示:
不论我们怎移动这个四边面,它绘制的内容始终是不变的。
只有当我们移动视角的时候,它的内容才会发生变化。
所以我们可以暂时断定,它确确实实是抓取目前摄像机所拍摄到的内容。额外注意一下,这里在抓取屏幕的时候是没有把这个模型考虑在内的,这也是为什么我们要把着色器的SubShader的Tags设置为
如果不设置的话,理论上就会出现循环捕捉现象。
sampler2D _MainTex;
sampler2D _BumpTex;
samplerCUBE _CubeMap;
float _Distortion;
fixed _RefractAmount;
sampler2D _RefractionTex;
//即使GrabPass,也要声明,不然没有地方填充变量
float4 _RefractionTex_TexelSize;
//Unity会把一个纹理的大小填充到一个叫
//纹理名_TexelSize 变量中,使用之前要声明
struct vertexInput{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
float4 tangent : TANGENT;
};
struct vertexOutput{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float4 scrPos : TEXCOORD1;
float4 TW1 : TEXCOORD2;
float4 TW2 : TEXCOORD3;
float4 TW3 : TEXCOORD4;
};
除了我们在Properties包围块中的输入声明,我们还增加了sampler2D _RefractionTex,因为GrabPass抓取到内容之后,也需要填充到一个实际存在的变量中去,否则就无法引用。为了对这个GrabPass抓取的纹理进行缩放,我们还增加了_RefractionTex_TexelSize变量的声明,我们之前说过,我们输入的纹理,只要我们想获得它的大小,就可以声明一个
纹理变量名_TexelSize
的变量,这样,Unity就会自动把这个纹理的大小填入这个变量中,它是一个float4类型的变量,定义如下
Vector4(1 / width, 1 / height, width, height)
我们一般需要zw分量,即宽和高。除了这些,我们注意到vertexOutput增加了一个叫做scrPos的变量,这个变量用于存储模型的屏幕空间位置。
vertexOutput Vertex(vertexInput v){
vertexOutput o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
o.scrPos = ComputeGrabScreenPos(o.pos);
float3 worldPos = mul(unity_ObjectToWorld,v.vertex).xyz;
float3 worldNormal = UnityObjectToWorldNormal(v.normal);
float3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
float3 worldBinormal = cross(worldNormal,worldTangent) * v.tangent.w;
o.TW1 = float4(worldTangent.x,worldBinormal.x,worldNormal.x,worldPos.x);
o.TW2 = float4(worldTangent.y,worldBinormal.y,worldNormal.y,worldPos.y);
o.TW3 = float4(worldTangent.z,worldBinormal.z,worldNormal.z,worldPos.z);
/* 计算切线空间转世界空间矩阵,记住是切线转世界*/
return o;
}
这里主要是计算切线转世界空间矩阵,不过我们记住多了一个计算步骤,ComputeGrabScreenPos函数用于计算模型的屏幕空间的采样位置。看起来好像可以过渡到片元着色器了,不过由于片元着色器稍微有点复杂,在跳转到片元着色器之前,我们先回到刚才的GrabPass,把下面的内容理解透彻了,片元着色器理解起来就没有任何问题了。
这是我们刚才讲解GrabPass用到的一个着色器,我们现在要做到,让它看起来和AlphaBlend(透明度混合)差不多一样。当然啦,由于这个四边面自己是没有颜色的,如果和AlphaBlend一样,它就消失了(类似于保护色),为了避免这点,我们为它增加自己的颜色。
在之前我们编写ComputeGrabScreenPos的时候,我发现看见了代码提示中的函数讲解。
任何一个线索都值得你认真对待,所以我们把它写下来,然后翻译过来
Computes texture coordinate for sampling a GrabPass texture. Input is clip space position.
没有太难的词汇,翻译如下。
计算采样一个GrabPass所需要的纹理坐标,输入是模型的裁剪空间坐标。
瓦特?这不就是在说明我们可以直接用它作为采样坐标来采样嘛?ok,既然如此,我们就直接抛弃了之前所使用的uv坐标,转而通过ComputeGrabScreenPos来采样。
vertexOutput Vertex(appdata_base v){
vertexOutput o;
o.pos = UnityObjectToClipPos(v.vertex);
o.screenPos = ComputeGrabScreenPos(o.pos);
return o;
}
fixed4 Pixel(vertexOutput i):SV_TARGET{
fixed3 color = tex2D(_GrabPassTexture,i.screenPos.xy).xyz * _BaseColor.xyz;
return fixed4(color,1.0);
}
观察上面的代码,我们通过ComputeGrabScreenPos来出了一个坐标,暂且命名为screenPos吧,另外由于ComputeGrabScreenPos这个函数输出是一个float4类型的变量,所以声明的时候注意screenPos是float4类型。
ok,赶紧来观察一下最终效果吧。
额,感觉最终效果好像和我想象的不太一样。最终的结果应该是像一张黄色玻璃纸一样,可以透过玻璃纸看到后面的内容
在这个方法失效之后,我们注意到一件事,那就是screenPos是float4类型的,为什么它是float4类型呢?我们有必要去解读一下ComputeGrabScreenPos的源代码。
我们先获取ComputeGrabScreenPos的源代码,大概如下。
inline float4 ComputeGrabScreenPos (float4 pos) {
#if UNITY_UV_STARTS_AT_TOP
float scale = -1.0;
#else
float scale = 1.0;
#endif
float4 o = pos * 0.5f;
o.xy = float2(o.x, o.y*scale) + o.w;
#ifdef UNITY_SINGLE_PASS_STEREO
o.xy = TransformStereoScreenSpaceTex(o.xy, pos.w);
#endif
o.zw = pos.zw;
return o;
}
该代码大概可以分为三部分
我们可以先理解一下UNITY_SINGLE_PASS_STEREO,这个宏用于指定是否开启了单通道立体渲染,目前仅用于VR平台。可以在以下的Unity官方文档中找到具体的讲解。不过我们这里不考虑VR的情况,所以暂时不考虑。
所以我们的问题核心就停留在了返回值的计算方式和UNITY_UV_STARTS_AT_TOP所表示的含义,从它的名字我们就可以略知一二,即UV坐标是否从上方开始。这里指的是什么呢?
很简单,就是区别OpenGL和DirectX两个平台。OpenGL的屏幕最直观了,OpenGL的屏幕坐标就相当于一个直角坐标系的第一象限,它的原点在左下角,屏幕的右上角代表了屏幕的大小。而DirectX和OpenGL唯一不同的就是它的纵坐标是反过来的。它的原点在屏幕的左上角,y坐标越大,则越靠近底边,如下图所示。
所以呢,UNITY_UV_STARTS_AT_TOP的意思就表示当前平台是否是d3d平台,如果是的话,那我们就准备将屏幕坐标乘以一个-1,将其反向。(因为Unity3D的渲染机制是遵循OpenGL传统的)
所以第一部分的代码我们就懂了,接着是第二部分。计算方式,为了验证一些自己的猜想,我没有用ComputeGrabScreenPos来计算,而是直接自己手动把ComputeGrabScreenPos函数中的内容抄了一遍,相当于手动计算。
o.pos就是我们所谓的齐次裁剪空间坐标,我们需要知道以下两点已知的条件。
如果你不知道这两点,可以参考我写的文章游戏技术杂谈3:OpenGL投影矩阵。如果觉得我写的不好,可以观察原文章,OpenGL投影矩阵,链接如下:
王子饼干:游戏开发技术杂谈3:OpenGL投影矩阵33 赞同 · 3 评论文章
OpenGL投影矩阵(Projection Matrix)构造方法www.cnblogs.com/leixinyue/p/11166135.html
ok,我们先设裁剪空间坐标为(xc,yc,zc,wc),设归一化设备空间坐标为(xn,yn,zn),我们用下面的等式还原上面的操作。
第一步,我们将裁剪坐标缩小一半:
由于我们采样只需要x坐标和y坐标,所以此处只需要观察x坐标和y坐标即可。接着我们将已经缩小一半的xc和yc增加wc的一半,由于它们都有因子1/2,所以可以提出来。
ok,这样就算计算完毕了。这个计算代表了什么呢?乍看有点莫名其妙的,但是不要忘记了我们还有一个重要的条件,即当裁剪坐标除以w分量时,会被归一化为归一化设备坐标。其分量范围在[-1,1]。我们可以尝试将currentPos除以w分量,一切就水落石出了。
又xc/wc是归一化设备坐标,范围在[-1,1],我们可以得到下列关系
芜湖~,现在就清楚啦,这些计算步骤也就是把[-1,1]映射回[0,1]而已,那么前面的所做就是为了做一个铺垫而已,但是它就是没有做出最后一步,除以w分量。也就是这一步需要我们手动来计算的。
现在,我们在采样坐标当中手动除以w分量。另外注意一下,由于我们之前的w分量已经被除以0.5了,所以我们要用o.pos.w重新覆盖之前screenPos中的w。
最后我们观察一下效果。
这样就没有任何问题了,它和单纯的透明度混合有着非常大的差异,看起来好像是透过了这个模型看到了后面一样,但其实是我们把屏幕捕捉到的图像抠出来一块贴在上面而已。
终于可以回到我们的玻璃特效着色器上来了,如果直接上玻璃特效着色器的代码,可能有一些人会一脸懵逼吧,有了上面的ComputeGrabScreenPos的解析,理解起来就容易很多了。
fixed4 Pixel(vertexOutput i):SV_TARGET{
float3 worldPos = float3(i.TW1.w,i.TW2.w,i.TW3.w);
fixed3 worldViewDir = normalize(UnityWorldSpaceViewDir(worldPos));
fixed3 bump = UnpackNormal(tex2D(_BumpTex,i.uv));
float2 offset = bump.xy * _Distortion * _RefractionTex_TexelSize.xy;
i.scrPos.xy += offset;
fixed3 refractColor = tex2D(_RefractionTex,i.scrPos.xy/i.scrPos.w).xyz;
bump = normalize(half3(dot(i.TW1.xyz,bump),dot(i.TW2.xyz,bump),dot(i.TW3.xyz,bump)));
fixed3 reflectDir = reflect(-worldViewDir,bump);
fixed4 texColor = tex2D(_MainTex,i.uv);
fixed3 relfectColor = texCUBE(_CubeMap,reflectDir).xyz * texColor.xyz;
fixed3 finalColor = lerp(relfectColor,refractColor,_RefractAmount);
return fixed4(finalColor,1);
}
在这里,我们还是先计算出了worldPos和worldViewDir的标准向量。然后对我们传入的BumpTex进行解包,下面一行是最关键的,我们先对计算出来的bump参数乘上了一个_Distortion,这个是用于对凹凸纹理进行缩放控制的参数,另外,我们还乘上了一个GrabPass纹理的宽高的倒数(TexelSize.xy储存的是1/width和1/height)。这个计算结果主要是用于对玻璃的采样坐标进行扭曲,这样我们透过这个玻璃看到后面的对象就会被扭曲。接着我们对GrabPass进行采样,采样结果即为折射色彩。
接着我们需要根据法线纹理来计算反射方向,计算出反射反向后,依据反射方向在我们给定的环境纹理中采样,作为反射色彩。
然后在在折射色彩和反射色彩之间用_RefractAmount进行插值,插值结果用于绘制模型表面。下面就是我们的玻璃的最终效果,可以看见透过玻璃看到后面的图像是扭曲的。
终于写完了,这篇博客的量还是挺大的,我不清楚是不是有人会认真的读完。我刚开始接触这里的时候感觉难度还是挺大的。我回想起了小时候玩《重装机兵》时候打巨炮阵地的时候,那个时候不太会玩这个游戏,打了好久也没有打过去。甚至留下了阴影,好多次,因为打巨炮阵地,打的我的坦克和战车的底盘脱落逃走了。然后用身体和巨炮刚,结果两下就挂了。C装置,主炮,副炮,引擎,底盘全损。后来回想起来的时候,难打的boss还太多了,戈麦斯,沙漠之舟,冰冻巨象,诺亚等等。巨炮阵地只是前期的一个小boss罢了,只要你没有放弃,这些东西始终会因为你的积累而变得容易,因为知识是不变的,而你是在成长的。
另外,本次使用到的所有代码都已经上传到了我的github,在下面的链接中可以找到。如果你喜欢这个系列的文章,欢迎给我的github贡献一颗⭐,感谢阅读。