一、利用深度纹理进行运动模糊
原理:摄像机传递深度纹理_CameraDepthTexture,通过SAMPLE_DEPTH_TEXTURE方法从深度纹理中采样深度值,在片元着色器中先进行获取到NDC坐标(i.uv.x * 2 -1, i.ux.y * 2 - 1, SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth) * 2 - 1),再使用投影*视角逆矩阵将NDC转世界坐标,再使用上一帧的投影*视角矩阵从当前帧世界坐标转换为上一帧的NDC坐标,通过上一帧NDC坐标和当前帧NDC坐标计算出像素移动向量velocity,在片元着色器用像素移动向量进行偏移uv坐标,用偏移后的坐标对主纹理采样,这个过程进行多次(3次)累加再求平均像素颜色值进行输出。
其中,NDC是指Normalized Divice Coordinates(归一化的设备坐标)即裁剪坐标的归一化;
投影*视角矩阵是投影矩阵和视角矩阵的复合矩阵,视角矩阵是能把世界坐标转视角坐标,投影矩阵能把视角坐标转裁剪坐标(即NDC坐标),因此我们可以通过投影*视角矩阵将世界坐标转NDC坐标。反过来,投影*视角逆矩阵是将NDC转世界。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class M_MotionBlurWithDepthTexture : M_PostEffectsBase
{
public Shader motionBlurShader;
private Material motionBlurMaterial = null;
public Material material
{
get
{
motionBlurMaterial = CheckShaderAndCreateMaterial(motionBlurShader, motionBlurMaterial);
return motionBlurMaterial;
}
}
[Range(0.0f, 1.0f)]
public float blurSize = 0.5f;
//需哟啊哦一个摄像机的视角和投影矩阵
private Camera myCamera;
public Camera camera
{
get
{
if(myCamera == null)
{
myCamera = GetComponent<Camera>();
}
return myCamera;
}
}
private Matrix4x4 previousViewProjectionMatrix;//上一帧的视角*投影矩阵
private void OnEnable()
{
//设置摄像机的depthTextureMode,这样摄像机就会将摄像机计算出的屏幕深度纹理传递到Shader
camera.depthTextureMode |= DepthTextureMode.Depth;
}
private void OnRenderImage(RenderTexture source, RenderTexture destination)
{
if(material != null)
{
material.SetFloat("_BlurSize", blurSize);
//为了将当前已转为世界空间下的NDC坐标转为上一帧的NDC坐标
material.SetMatrix("_PreviousViewProjectionMatrix", previousViewProjectionMatrix);//上一帧的投影*视角矩阵
Matrix4x4 currentViewProjectionMatrix = camera.projectionMatrix * camera.worldToCameraMatrix;//投影*视角矩阵
Matrix4x4 currentViewProjectionInverseMatrix = currentViewProjectionMatrix.inverse;//投影*视角逆矩阵
//设置当前的逆矩阵,为了将当前NDC坐标转到世界空间下
material.SetMatrix("_CurrentViewProjectionInverseMatrix", currentViewProjectionInverseMatrix);
//准备下一次的上一帧的投影*视角矩阵
previousViewProjectionMatrix = currentViewProjectionMatrix;
//进行渲染得到速度纹理
Graphics.Blit(source, destination, material);
}
else
{
Graphics.Blit(source, destination);
}
}
}
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'
Shader "MilkShader/Twently/MotionBlurWithDepthTexture"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_BlurSize ("Blur Size", Float) = 1.0
}
SubShader
{
CGINCLUDE
#include "UnityCG.cginc"
sampler2D _MainTex;
half4 _MainTex_TexelSize;
sampler2D _CameraDepthTexture;
float4x4 _CurrentViewProjectionInverseMatrix;
float4x4 _PreviousViewProjectionMatrix;
half _BlurSize;
struct v2f{
float4 pos :SV_POSITION;
half2 uv : TEXCOORD0;
half2 uv_depth : TEXCOORD1;
};
v2f vert(appdata_img v){
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
o.uv_depth = v.texcoord;
#if UNITY_UV_STARTS_AT_TOP
if(_MainTex_TexelSize.y < 0){
o.uv_depth.y = 1 - o.uv_depth.y;
}
#endif
return o;
}
fixed4 frag(v2f i) : SV_Target{
float d = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth);
float4 H = float4(i.uv.x * 2 - 1,i.uv.y * 2 - 1,d * 2 - 1, 1);
//NDC转世界
float4 D = mul(_CurrentViewProjectionInverseMatrix, H);
float4 worldPos = D / D.w;
//当前NDC
float4 currentPos = H;
//上一帧NDC
float4 previousPos = mul(_PreviousViewProjectionMatrix, worldPos);
previousPos /= previousPos.w;
float2 velocity = (currentPos.xy - previousPos.xy) / 2.0f;
float2 uv = i.uv;
float4 c = tex2D(_MainTex, uv);
//根据速度偏移纹理坐标,进行取样3次累加
uv += velocity * _BlurSize;
for(int it = 1; it < 3; it++, uv += velocity * _BlurSize){
float4 currentCol = tex2D(_MainTex, uv);
c += currentCol;
}
c /= 3;//取平均
return fixed4(c.rgb, 1.0);
}
ENDCG
Pass
{
ZTest Always Cull Off ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
ENDCG
}
}
Fallback Off
}
二、全局雾效
基于高度的雾效,它也是属于屏幕后处理的
关键部分是重构每个像素在世界空间下的位置,与之前利用投影*视角逆矩阵不同,它是通过世界空间下摄像机到像素点的向量来计算出像素的世界坐标的。之后,我们通过这个世界坐标来计算出每一个像素点的雾气浓度,理论上来说世界坐标y值越小的,雾气浓度越高。
所以,怎么拿到世界空间下的摄像机到像素点的向量呢?(需要一定的想象力来理解我纯文字表达)
我们计算是在摄像机是透视模式下!!
摄像机Near是近截面距离,近截面有四个点(左上角TL,右上角TR,左下角BL,右下角BR),我们首先计算出从摄像机到这四个角的向量。
摄像机视角弧度:camera.fieldOfView(可见上图是60)
近截面halfHeight = tan(camera.fieldOfView / 2 * Mathf.Deg2Rad) * Near
近截面halfWidth = halfHeight * camera.aspect(近截面宽高比)
toRight = cameraTransform.right * halfWidth; //一个向量用于左右偏移的
toTop = cameraTransform.up * halfHeight; //一个向量用于上下偏移
TL = cameraTransform.forward * Near + toTop - toRight;
TR = cameraTransform.forward * Near + toTop + toRight;
BL = cameraTransform.forward * Near - toTop - toRight;
BR = cameraTransform.forward * Near - toTop + toRight;
至此我们求出了从摄像机到近截面四个角的向量。(这还只是开始!后面的才是真正的难以理解!当然我会尽可能地说明白!)
利用三角形近似原理,我们能知道Near / TL的长度 = 紫色点depth / 紫色点向量的长度
因此,紫色点向量 = 归一化的TL * 紫色点向量长度,注意TL我们是已知的,为此我们要求出紫色点向量长度!
紫色点向量长度 = (TL的长度 / Near) * 紫色点depth, 其中TL的长度和Near已知! 因此只有一个紫色点depth未知!
这个紫色点depth就是从摄像机传递的屏幕深度纹理中采样得到的!当求出紫色点向量后,我们就能通过摄像机世界坐标 + 紫色点向量,从而拿到紫色点的世界坐标,可以把紫色点看做为屏幕像素点去理解。因此在TL向量方向的射线上的像素坐标点我们都可以通过这种方式求出,而TR,BL,BR的也是同理,而且TR,BL,BR的长度和TL一样,因此我们可以将已知部分提取出来传递入Shader,即 归一化的TL * (TL的长度 / Near) 作为数据传递入shader,在片元着色器中获取到屏幕像素点深度值depth之后,用这个归一化的TL * (TL的长度 / Near) * depth 拿到摄像机到像素点的向量,从而求出世界空间下的像素坐标。(懵逼了吧?)
(TL忘记写了,抱歉)
在shader的顶点着色器中,会根据uv坐标的位置来判断应该传递哪个角的已知数据(一个未完全向量)进入片元着色器。
注意:一共有四个未完全向量,每一个未完全向量都对应着一个区域(如上图红色框)
左上角区域(TL)的未完全向量为 归一化的TL * (TL的长度/ Near)
右上角区域(TR)的未完全向量为 归一化的TR * (TR的长度/ Near)
左下角区域(BL)的未完全向量为 归一化的BL * (BL的长度/ Near)
右下角区域(BR)的未完全向量为 归一化的BR * (BR的长度/ Near)
由于我们已知TL、TR、BL、BR的长度是一样的,所以可以看到我写出的代码是用了scale = TL的长度 / Near;
这个未完全向量传入片元着色器时,实际上UNITY会做这个向量进行插值,不然没法解释为什么中间那些像素点是怎么求的。因为我们传入的未完全向量只是四个边角的向量!
因此在片元着色器里面就可以用这个未完全向量 * 像素点深度值 拿到 摄像机到像素点的向量了!!!(什么,你没听明白?可以重复看一次,这个过程会有些复杂,但是你看书上的可能更加懵逼(我尽力了。。)下面直接上代码看吧。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class M_FogWithDepthTexture : M_PostEffectsBase
{
public Shader fogShader;
private Material fogMaterial = null;
public Material material
{
get
{
fogMaterial = CheckShaderAndCreateMaterial(fogShader, fogMaterial);
return fogMaterial;
}
}
private Camera myCamera;
private Camera camera
{
get
{
if (myCamera == null)
{
myCamera = GetComponent<Camera>();
}
return myCamera;
}
}
private Transform myCameraTransf;
public Transform cameraTransform
{
get
{
if (myCameraTransf == null)
{
myCameraTransf = camera.transform;
}
return myCameraTransf;
}
}
//控制雾浓度
[Range(0f, 3f)]
public float fogDensity = 1f;
//颜色
public Color fogColor = Color.white;
//开始高度
public float fogStart = 0f;
//结束高度
public float fogEnd = 2f;
private void OnEnable()
{
//摄像机的深度纹理传递到Shader [_CameraDeepTexture]
camera.depthTextureMode = DepthTextureMode.Depth;
}
private void OnRenderImage(RenderTexture source, RenderTexture destination)
{
if (material != null)
{
Matrix4x4 frustumCorners = Matrix4x4.identity;
float fov = camera.fieldOfView;//摄像机的视角弧度
float near = camera.nearClipPlane;
float far = camera.farClipPlane;
float aspect = camera.aspect;
float halfHeight = near * Mathf.Tan(fov * 0.5f * Mathf.Deg2Rad);// Mathf.Deg2Rad是单位弧度的度数 即弧度转角度用的
Vector3 toRight = cameraTransform.right * halfHeight * aspect;
Vector3 toTop = cameraTransform.up * halfHeight;
//四个未完全向量
Vector3 topLeft = cameraTransform.forward * near + toTop - toRight;
float scale = topLeft.magnitude / near;
topLeft.Normalize();
topLeft *= scale;
Vector3 topRight = cameraTransform.forward * near + toTop + toRight;
topRight.Normalize();
topRight *= scale;
Vector3 bottomLeft = cameraTransform.forward * near - toTop - toRight;
bottomLeft.Normalize();
bottomLeft *= scale;
Vector3 bottomRight = cameraTransform.forward * near - toTop + toRight;
bottomRight.Normalize();
bottomRight *= scale;
frustumCorners.SetRow(0, bottomLeft);
frustumCorners.SetRow(1, bottomRight);
frustumCorners.SetRow(2, topRight);
frustumCorners.SetRow(3, topLeft);
//传入四个区域的未完全向量
material.SetMatrix("_FrustumCornersRay", frustumCorners);
投影*视角逆矩阵
//material.SetMatrix("_ViewProjectionInverseMatrix", (camera.projectionMatrix *
// camera.worldToCameraMatrix).inverse);
//雾浓度
material.SetFloat("_FogDensity", fogDensity);
//雾颜色
material.SetColor("_FogColor", fogColor);
//雾气起点y和雾气终点y,可以自己调整下看看效果
material.SetFloat("_FogStart", fogStart);
material.SetFloat("_FogEnd", fogEnd);
Graphics.Blit(source, destination, material);
}
else
{
Graphics.Blit(source, destination);
}
}
}
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'
Shader "MilkShader/13/M_FogWithDepthTexture"
{
Properties
{
_MainTex ("Base(RGB)", 2D) = "white" {}
_FogDensity("Fog Density", Float) = 1.0
_FogColor ("Fog Color", Color) = (1,1,1,1)
_FogStart("Fog Start", Float) = 0.0
_FogEnd("Fog End", Float) = 1.0
}
SubShader
{
CGINCLUDE
#include "UnityCG.cginc"
float4x4 _FrustumCornersRay;
sampler2D _MainTex;
half4 _MainTex_TexelSize;
sampler2D _CameraDepthTexture;
half _FogDensity;
fixed4 _FogColor;
float _FogStart;
float _FogEnd;
struct v2f{
float4 pos : SV_POSITION;
half2 uv :TEXCOORD0;
half2 uv_depth :TEXCOORD1;
float4 interpolatedRay :TEXCOORD2;
};
v2f vert(appdata_img v){
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
o.uv_depth = v.texcoord;
#if UNITY_UV_STARTS_AT_TOP
if(_MainTex_TexelSize.y<0) {
o.uv_depth = 1 - o.uv_depth;
}
#endif
int index = 0;
//左下角
if(v.texcoord.x < 0.5 && v.texcoord.y < 0.5){
index = 0;
}else if(v.texcoord.x > 0.5 && v.texcoord.y < 0.5){//右下角
index = 1;
}
else if(v.texcoord.x > 0.5 && v.texcoord.y > 0.5){//右上角
index = 2;
}else{
index = 3;//左上角
}
//这是一种兼容DirectX的处理方式,反转区域
#if UNITY_UV_STARTS_AT_TOP
if(_MainTex_TexelSize.y < 0){
index = 3 - index;
}
#endif
//传入对应的未完全向量
o.interpolatedRay = _FrustumCornersRay[index];
return o;
}
fixed4 frag(v2f i) : SV_Target{
//注意:LinearEyeDepth是拿到视角空间下(即摄像机为坐标系的)的深度值,从深度纹理采样出的深度值是裁剪空间下的
float linearDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth));
//世界坐标点 = 摄像机世界坐标点 + 深度值 * 未完全向量 = 摄像机世界坐标点 + 世界下的摄像机到像素点的向量
float3 worldPos = _WorldSpaceCameraPos + linearDepth * i.interpolatedRay.xyz;
//下面就是用这个坐标求浓度,y越小的越浓
float fogDensity = (_FogEnd - worldPos.y) / (_FogEnd - _FogStart);
fogDensity = saturate(fogDensity * _FogDensity);
fixed4 finalColor = tex2D(_MainTex, i.uv);
finalColor.rgb = lerp(finalColor.rgb, _FogColor.rgb , fogDensity);//浓度作为插值百分比
return finalColor;
}
ENDCG
Pass
{
ZTest Always Cull Off ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
ENDCG
}
}
Fallback Off
}
三、边缘检测
利用Roberts算子进行的边缘检测,之前的边缘检测是用Sobel算子,Sobel算子计算的是梯度,横向梯度和纵向梯度求法是用了2个3*3矩阵,一个是横向一个是纵向,Roberts算子也有2个矩阵,一个是左上角到右下角,另一个是右上角到左下角。Roberts算子计算像素点邻边的法线差值和深度差值,而Sobel算子计算的是像素点横/纵颜色梯度。
原理很简单,就是计算一下左上角和右下角的像素点 或 右上角和左下角的像素点 的法线之差 和 深度之差,如果大于某个阈值就说明是边缘点,直接上代码,代码有注释。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class M_EdgeDetectNormalsAndDepth : M_PostEffectsBase {
public Shader edgeDetectShader;
private Material edgeDectectMaterial = null;
public Material material
{
get
{
edgeDectectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, edgeDectectMaterial);
return edgeDectectMaterial;
}
}
//用于边缘颜色+原图颜色的颜色输出值与边缘颜色+背景颜色的颜色输出值的插值百分比
//为1时,它仅有边缘颜色+背景颜色
[Range(0f, 1f)]
public float edgesOnly = 0f;
//边缘颜色
public Color edgeColor = Color.black;
//背景颜色
public Color backgroundColor = Color.white;
//采样间距:越大描边越宽
public float sampleDistance = 1.0f;
//对深度纹理采样的灵敏度:用于放大深度差值,当灵敏度很大时,深度相差一点也会被认为是边缘
public float sensitivityDepth = 1.0f;
//对法线纹理采样的灵敏度:用于放大法线差值,当灵敏度很大时,法线相差一点也会被认为是边缘(我是复读机)
public float sensitivityNormals = 1.0f;
private void OnEnable()
{
//摄像机传递一个深度&法线纹理 _CameraDepthNormalsTexture
GetComponent<Camera>().depthTextureMode = DepthTextureMode.DepthNormals;
}
//由于默认情况下,OnRenderImage是在所有不透明和透明物体渲染之后进行的
//此时想在不透明物体渲染完之后立即执行就得加上[ImageEffectOpaque]
//因为我们不希望透明物体也被描边,而仅仅是不透明的物体被描边
[ImageEffectOpaque]
private void OnRenderImage(RenderTexture source, RenderTexture destination)
{
if (material != null)
{
material.SetFloat("_EdgeOnly", edgesOnly);
material.SetColor("_EdgeColor", edgeColor);
material.SetColor("_BackgroundColor", backgroundColor);
material.SetFloat("_SampleDistance", sampleDistance);
material.SetVector("_Sensitivity", new Vector4(sensitivityNormals, sensitivityDepth, 0.0f, 0.0f));
Graphics.Blit(source, destination, material);
}
else
{
Graphics.Blit(source, destination);
}
}
}
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'
Shader "Unlit/M_EdgeDectectNormalAndDepth"
{
Properties
{
_MainTex ("Base(RGB)", 2D) = "white" {}
_EdgeOnly ("Edge Only", Float) = 1.0
_EdgeColor ("Edge Color", Color) = (1,1,1,1)
_BackgroundColor ("Background Color", Color) = (1,1,1,1)
_SampleDistance("Sample Distance", Float) = 1.0
_Sensitivity ("Sensitivity", Vector) = (1,1,1,1)//x,y分别对应了法线和深度的检测灵敏度,z,w没用途
}
SubShader
{
CGINCLUDE
#include "UnityCG.cginc"
sampler2D _MainTex;
half4 _MainTex_TexelSize;
float _EdgeOnly;
fixed4 _EdgeColor;
fixed4 _BackgroundColor;
float _SampleDistance;
half4 _Sensitivity;
sampler2D _CameraDepthNormalsTexture;//深度+法线纹理(摄像机传递)
struct v2f{
float4 pos : SV_POSITION;
half2 uv[5] : TEXCOORD0;
};
v2f vert(appdata_img v){
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
half2 uv = v.texcoord;
o.uv[0] = uv;
#if UNITY_UV_STARTS_AT_TOP
if(_MainTex_TexelSize.y < 0){
uv.y = 1 - uv.y;
}
#endif
o.uv[1] = uv + _MainTex_TexelSize.xy * half2(1,1) * _SampleDistance;
o.uv[2] = uv + _MainTex_TexelSize.xy * half2(-1,-1) * _SampleDistance;
o.uv[3] = uv + _MainTex_TexelSize.xy * half2(-1,1) * _SampleDistance;
o.uv[4] = uv + _MainTex_TexelSize.xy * half2(1,-1) * _SampleDistance;
return o;
}
half CheckSame(half4 center, half4 sample){
//基准法线和深度
half2 centerNormal = center.xy;
float centerDepth = DecodeFloatRG(center.zw);//DecodeFloatRG解码函数
//样品法线和深度
half2 sampleNormal = sample.xy;
float sampleDepth = DecodeFloatRG(sample.zw);//DecodeFloatRG解码函数
//法线差值 * 法线灵敏度 = 最终法线差值
half2 diffNormal = abs(centerNormal - sampleNormal) * _Sensitivity.x;
//法线x,y的分量之和 小于 0.1 代表法线上没有边缘 返回1给isSameNormal, 否则 >= 0.1 认为法线上存在边缘 返回0给isSameNormal
int isSameNormal = (diffNormal.x + diffNormal.y) < 0.1; // 0.1就是法线阈值(可自己自定义)
//深度差值 * 深度灵敏度
float diffDepth = abs(centerDepth - sampleDepth) * _Sensitivity.y;
//若深度差值 < 0.1 * 基准深度 ,说明深度上没有边缘 返回1给isSameDepth,否则 >= 0.1 * 基准深度 ,认为深度上存在边缘 返回0给isSameDepth
int isSameDepth = diffDepth < 0.1 * centerDepth; // 0.1 * centerDepth是深度阈值
//只要有一个是0,就会返回0,否则2个是1的话,就返回1,
//可见只要有法线或深度其中一个的插值大于阈值,就说明肯定是边缘了!
//返回1 代表没有边缘,否则是有边缘
return isSameDepth * isSameNormal ? 1.0 : 0.0;
}
fixed4 fragRobertsCrossDepthAndNormal(v2f i) : SV_Target{
//采样出右上角、左下角、左上角、右下角的法线深度值(x,y,z,w) 其中,x,y是法线,z,w是深度
half4 sample1 = tex2D(_CameraDepthNormalsTexture, i.uv[1]);
half4 sample2 = tex2D(_CameraDepthNormalsTexture, i.uv[2]);
half4 sample3 = tex2D(_CameraDepthNormalsTexture, i.uv[3]);
half4 sample4 = tex2D(_CameraDepthNormalsTexture, i.uv[4]);
half edge = 1.0;
//CheckSame函数简单来说就是,看看这2个点的法线和深度的插值是否大于阈值,若大于阈值会返回0
//只要有一个情况是返回0,那就肯定是边缘!
edge *= CheckSame(sample1, sample2);
edge *= CheckSame(sample3, sample4);
//edge要么是1 要么是0,如果是0,那就是边缘颜色
fixed4 withEdgeColor = lerp(_EdgeColor, tex2D(_MainTex, i.uv[0]), edge);
fixed4 onlyEdgeColor = lerp(_EdgeColor, _BackgroundColor, edge);
//_EdgeOnly 为 1时候 仅有边缘颜色 + 背景颜色 ,否则只是边缘颜色 + 原图颜色
return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);
}
ENDCG
Pass
{
ZTest Always Cull Off ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment fragRobertsCrossDepthAndNormal
ENDCG
}
}
Fallback Off
}
这里有一个很蛋疼的问题,那就是书上说可以用Graphics.DrawMesh来进行对指定物体的描边效果,原理大概是先进行对整个屏幕渲染一次, 再进行第二次渲染,即对一个指定物体用边缘颜色进行填充,然后利用Roberts算子进行边缘检测(也就是看看深度和法线差值是否大于某个阈值)的方法进行识别出并非是边缘的像素点,然后用clip函数剔除掉这些像素点,以此还原为原色。第一次渲染的是原图(原封不动地渲染出屏幕渲染纹理),第二次渲染是针对某个物体的边缘检测渲染出来指定物体的边缘颜色,然后将这2个渲染结果叠加一下就得到了最终的效果图,然而。我TM的做不出来。有哪位大佬知道怎么做的,直接甩我一个源码工程,感激不尽!!!
如果对上面代码或某些地方有疑问的话,可以直接在评论区域留言