内容偏多

使用软件 Unity 2021.3.15 后处理实现描边效果
后处理实现描边效果
这样做有什么需要注意的地方
效果

我们准备一个默认的 Renderer Feature 在只需要把Shader渲染出来就可以
管线准备
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
public class Outlint : ScriptableRendererFeature
{
[System.Serializable]
public class Settings
{
public RenderPassEvent renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing;
public Shader shader;
}
public Settings settings = new Settings();
OutlintPass outlintPass; // 定义我们创建出Pass
public override void Create()
{
this.name = "Bilt"; // 模糊渲染的名字
outlintPass = new OutlintPass(RenderPassEvent.BeforeRenderingPostProcessing, settings.shader); // 初始化 我们的渲染层级和Shader
}
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
renderer.EnqueuePass(outlintPass);
}
}
public class OutlintPass : ScriptableRenderPass
{
static readonly string RenderTag = "Post Effects"; // 设置渲染标签
OutlintVolume outlintvolume; // 定义组件类型
Material biltmaterial; // 后处理材质
public OutlintPass(RenderPassEvent evt, Shader biltshader)
{
renderPassEvent = evt;
var shader = biltshader;
if (shader == null)
{
Debug.LogError("没有指定Shader");
return;
}
biltmaterial = CoreUtils.CreateEngineMaterial(biltshader);
}
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
if (biltmaterial == null)
{
Debug.LogError("材质初始化失败");
return;
}
if (!renderingData.cameraData.postProcessEnabled)
{
return;
}
var stack = VolumeManager.instance.stack; // 传入 volume
outlintvolume = stack.GetComponent<OutlintVolume>(); // 获取到后处理组件
var cmd = CommandBufferPool.Get(RenderTag); // 渲染标签
Render(cmd, ref renderingData); // 调用渲染函数
context.ExecuteCommandBuffer(cmd); // 执行函数,回收。
CommandBufferPool.Release(cmd);
}
void Render(CommandBuffer cmd, ref RenderingData renderingData)
{
RenderTargetIdentifier source = renderingData.cameraData.renderer.cameraColorTarget; // 定义RT
RenderTextureDescriptor inRTDesc = renderingData.cameraData.cameraTargetDescriptor;
inRTDesc.depthBufferBits = 0; // 清除深度
// 增加控制Shader的属性
int destination = Shader.PropertyToID("Temp1");
// 获取一张临时RT
cmd.GetTemporaryRT(destination, inRTDesc.width, inRTDesc.height, 0, FilterMode.Bilinear, RenderTextureFormat.DefaultHDR); //申请一个临时图像,并设置相机rt的参数进去
cmd.Blit(source, destination); // 设置后处理
cmd.Blit(destination, source, biltmaterial, 0); // 第二个Pass
}
}
URP | 后处理-自定义后处理 - 哔哩哔哩 (bilibili.com)

上面定好后处理管线了,那我们开始制作Shader, 核心算法是在Shader中。
提供默认Shader模板
Shader "Hidden/Outlint"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
}
SubShader
{
Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline"}
LOD 100
HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
CBUFFER_START(UnityPerMaterial)
float4 _MainTex_ST;
CBUFFER_END
struct appdata
{
float4 positionOS : POSITION;
float2 texcoord : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 positionCS : SV_POSITION;
};
TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex);
ENDHLSL
Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
v2f vert (appdata v)
{
v2f o;
VertexPositionInputs PositionInputs = GetVertexPositionInputs(v.positionOS.xyz);
o.positionCS = PositionInputs.positionCS;
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
return o;
}
half4 frag (v2f i) : SV_Target
{
half4 col = SAMPLE_TEXTURE2D(_MainTex,sampler_MainTex,i.uv);
return col;
}
ENDHLSL
}
}
} 为了计算轮廓,计算相邻像素进行采样比较两个像素的值,如果数值不同,就判断是边缘绘制一条线。
使用深度计算我们的边缘,在深度缓存中以X形状进行采样,定义我们的渲染储存大小 _MainTex_TexelSize,_Scale
float halfScaleFloor = floor(_Scale * 0.5);
float halfScaleCeil = ceil(_Scale * 0.5);
float2 bottomLeftUV = i.uv - float2(_MainTex_TexelSize.x, _MainTex_TexelSize.y) * halfScaleFloor;
float2 topRightUV = i.uv + float2(_MainTex_TexelSize.x, _MainTex_TexelSize.y) * halfScaleCeil;
float2 bottomRightUV = i.uv + float2(_MainTex_TexelSize.x * halfScaleCeil, -_MainTex_TexelSize.y * halfScaleFloor);
float2 topLeftUV = i.uv + float2(-_MainTex_TexelSize.x * halfScaleFloor, _MainTex_TexelSize.y * halfScaleCeil); 我们首先计算两个值,然后.这两个值将随着增加而递增 1。通过以这种方式缩放UV,我们能够一次仅增加一个像素的边缘宽度 - 实现最大可能的粒度 - 同时仍然保持坐标的中心。

扩展_MainTex_TexelSize和_MainTex_ST 的区别?
_MainTex_TexelSize 是贴图 _MainTex 的像素尺寸大小,值: Vector4(1 / width, 1 / height, width, height)half2 offs = _MainTex_TexelSize.xy * half2(1,0) * _BlurSize;
_MainTex_ST 是贴图_MainTex的tiling和offset的四元数_MainTex_ST.xy 是tiling的值_MainTex_ST.zw 是offset的值// Transforms 2D UV by scale/bias property #define TRANSFORM_TEX(tex,name) (tex.xy * name##_ST.xy + name##_ST.zw)
注意:这里前到 后处理和管线中增加 控制 Scale 属性
后处理

管线中增加控制Shader

这样后续才能显示正确

采样深度
URP | Depth 深度 - 哔哩哔哩 (bilibili.com)
使用我们计算出来的UV对深度进行采样。
增加深度

因为我们是4个方向,所以使用4个方向采样深度。
float depth0 = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, bottomLeftUV).r;
float depth1 = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, topRightUV).r;
float depth2 = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, bottomRightUV).r;
float depth3 = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, topLeftUV).r; 我们输出一个深度查看一下效果,

效果

使用减法来比较像素之间的不同深度。

由于差值可以是正数或负数,因此我们在返回结果之前取其绝对值。由于附近深度值之间的差异可能非常小(因此很难在屏幕上看到),我们将差异乘以 100 以使其更容易看到。
效果

这个是检测边缘的一半,depthFiniteDifference1 是另一半。
我们现在需要把这俩个合并成一个,
float edgeDepth = sqrt(pow(depthFiniteDifference0, 2) + pow(depthFiniteDifference1, 2)) * 100; 效果

我们看到表面还是有很多灰色区域,我们希望是只有黑和白。

增加一个变量控制,黑白

float edgeDepth = sqrt(pow(depthFiniteDifference0, 2) + pow(depthFiniteDifference1, 2)) * 100;
edgeDepth = edgeDepth > _DepthThreshold ? 1 : 0; 到后处理中增加控制变量的方法,

并且增加到 Render里

效果

出现大面积的白色区域,这些区域不是我想要的,我们对表面深度进行调整。

效果

我们要获取深度法线,我们在管线中增加一个SSAO ,SSAO自带深度法线
SSAO

使用深度法线来绘制,不是深度,我们最后把两者结合起来,
Shader中增加法线深度

使用同样的方法调用
// 深度法线
float3 normal0 = SAMPLE_TEXTURE2D(_CameraNormalsTexture, sampler_CameraNormalsTexture, bottomLeftUV).rgb;
float3 normal1 = SAMPLE_TEXTURE2D(_CameraNormalsTexture, sampler_CameraNormalsTexture, topRightUV).rgb;
float3 normal2 = SAMPLE_TEXTURE2D(_CameraNormalsTexture, sampler_CameraNormalsTexture, bottomRightUV).rgb;
float3 normal3 = SAMPLE_TEXTURE2D(_CameraNormalsTexture, sampler_CameraNormalsTexture, topLeftUV).rgb;
float3 normalFiniteDifference0 = normal1 - normal0;
float3 normalFiniteDifference1 = normal3 - normal2;
float edgeNormal = sqrt(dot(normalFiniteDifference0, normalFiniteDifference0) + dot(normalFiniteDifference1, normalFiniteDifference1));
edgeNormal = edgeNormal > _NormalThreshold ? 1 : 0;
return edgeNormal; 我们前输出看一下深度法线是否起作用,

效果

显示这样的效果就是正确的,如果是黑色的还是没有获取到深度法线。
现在输出 edgeNormal 法线计算的边缘

我们可以看到法线产生了一些新的边缘,原来的边缘效果有一些消失了,
我们把这俩种方法结合起来。

效果

我们看到平面有时候一片白色的区域
可以调大 深度处理来处理,但是会出现法线边缘不完整的情况。


为什么出现白色边缘?
表面的斜率越大,相邻像素深度之间的差异就越大。沿着这些表面的这种大深度增量导致我们的算法检测它们上的“边缘”
为了实现这一点,我们需要每个表面的法线,以及从相机到表面的视角方向。
我们计算视角方向,
我们使用发法线深度是在屏幕空间中,所以我们摄像机视角方向也需要在屏幕空间才可以计算,
我们需要视角空间转换到屏幕空间,我们需要反向投影矩阵。
后处理脚本中我们计算视角方向

Shader中获取后处理传入的View
float4x4 _ClipToView; 我们Shader计算屏幕空间中的视角方向。
v2f vert (appdata v)
{
v2f o;
VertexPositionInputs PositionInputs = GetVertexPositionInputs(v.positionOS.xyz);
o.positionCS = PositionInputs.positionCS;
// uv
o.uv = TransformTriangleVertexToUV(o.positionCS.xy);
#if UNITY_UV_STARTS_AT_TOP
o.uv = o.uv * float2(1.0, -1.0) + float2(0.0, 1.0);
#endif
o.texcoordStereo = TransformStereoScreenSpaceTex(o.uv, 1.0);
float4 vertex = float4(o.positionCS.xy, 0.0, -1.0);
o.viewSpaceDir = mul(_ClipToView, vertex).xyz;
return o;
} 片元着色器阶段来处理计算法线和视角关系
float3 viewNormal = normal0 * 2 - 1;
float NdotV = 1 - dot(viewNormal, -i.viewSpaceDir);
float normalThreshold01 = saturate((NdotV - _DepthNormalThreshold) / (1 - _DepthNormalThreshold));
float normalThreshold = normalThreshold01 * _DepthNormalThresholdScale + 1;
float depthThreshold = _DepthThreshold * depth0 * normalThreshold;
float depthFiniteDifference0 = depth1 - depth0;
float depthFiniteDifference1 = depth3 - depth2;
float edgeDepth = sqrt(pow(depthFiniteDifference0, 2) + pow(depthFiniteDifference1, 2)) * 100;
edgeDepth = edgeDepth > depthThreshold ? 1 : 0; 我们在合并输出,一个是边缘的颜色,一个原图颜色。
float edge = max(edgeDepth, edgeNormal);
// 边缘颜色
float4 edgeColor = float4(_Color.rgb, _Color.a * edge);
float4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
return alphaBlend(edgeColor, color); 注意:记得在Volume增加颜色控制。
效果


后处理组件,我们在后处理组件中定义我们刚刚Shader中创建的变量属性,
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
public class OutlintVolume : VolumeComponent, IPostProcessComponent
{
[Tooltip("边缘颜色")]
public ColorParameter OutlintColor = new ColorParameter(Color.white);
[Tooltip("边缘检测大小")]
public ClampedFloatParameter Scale = new ClampedFloatParameter(1f, 0f, 10f);
[Tooltip("深度")]
public ClampedFloatParameter DepthThreshold = new ClampedFloatParameter(0.2f, 0f, 10f);
[Tooltip("法线深度")]
public ClampedFloatParameter NormalThreshold = new ClampedFloatParameter(0.4f, 0f, 1f);
public ClampedFloatParameter DepthNormalThreshold = new ClampedFloatParameter(0.5f, 0f, 1f);
public ClampedFloatParameter DepthNormalThresholdScale = new ClampedFloatParameter(7f, 0f, 10f);
public bool IsActive() => Scale.value > 0;
public bool IsTileCompatible() => false;
} 效果

Shader
Shader "Hidden/Outlint"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
}
SubShader
{
Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline"}
LOD 100
HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
CBUFFER_START(UnityPerMaterial)
float4 _MainTex_ST;
float4 _MainTex_TexelSize;
float4x4 _ClipToView;
float4 _Color;
float _Scale;
float _DepthThreshold;
float _NormalThreshold;
float _DepthNormalThreshold;
float _DepthNormalThresholdScale;
CBUFFER_END
struct appdata
{
float4 positionOS : POSITION;
float2 texcoord : TEXCOORD0;
};
struct v2f
{
float4 positionCS : SV_POSITION;
float2 uv : TEXCOORD0;
float3 viewSpaceDir : TEXCOORD1;
float2 texcoordStereo : TEXCOORD2;
};
TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex);
TEXTURE2D(_CameraDepthTexture); SAMPLER(sampler_CameraDepthTexture);
TEXTURE2D(_CameraNormalsTexture); SAMPLER(sampler_CameraNormalsTexture);
ENDHLSL
Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
float _RenderViewportScaleFactor;
float2 TransformStereoScreenSpaceTex(float2 uv, float w)
{
float4 scaleOffset = unity_StereoScaleOffset[unity_StereoEyeIndex];
scaleOffset.xy *= _RenderViewportScaleFactor;
return uv.xy * scaleOffset.xy + scaleOffset.zw * w;
}
float2 TransformTriangleVertexToUV(float2 vertex)
{
float2 uv = (vertex + 1.0) * 0.5;
return uv;
}
float4 alphaBlend(float4 top, float4 bottom)
{
float3 color = (top.rgb * top.a) + (bottom.rgb * (1 - top.a));
float alpha = top.a + bottom.a * (1 - top.a);
return float4(color, alpha);
}
v2f vert (appdata v)
{
v2f o;
VertexPositionInputs PositionInputs = GetVertexPositionInputs(v.positionOS.xyz);
o.positionCS = PositionInputs.positionCS;
// uv
o.uv = TransformTriangleVertexToUV(o.positionCS.xy);
#if UNITY_UV_STARTS_AT_TOP
o.uv = o.uv * float2(1.0, -1.0) + float2(0.0, 1.0);
#endif
o.texcoordStereo = TransformStereoScreenSpaceTex(o.uv, 1.0);
float4 vertex = float4(o.positionCS.xy, 0.0, -1.0);
o.viewSpaceDir = mul(_ClipToView, vertex).xyz;
return o;
}
half4 frag (v2f i) : SV_Target
{
float halfScaleFloor = floor(_Scale * 0.5);
float halfScaleCeil = ceil(_Scale * 0.5);
float2 bottomLeftUV = i.uv - float2(_MainTex_TexelSize.x, _MainTex_TexelSize.y) * halfScaleFloor;
float2 topRightUV = i.uv + float2(_MainTex_TexelSize.x, _MainTex_TexelSize.y) * halfScaleCeil;
float2 bottomRightUV = i.uv + float2(_MainTex_TexelSize.x * halfScaleCeil, -_MainTex_TexelSize.y * halfScaleFloor);
float2 topLeftUV = i.uv + float2(-_MainTex_TexelSize.x * halfScaleFloor, _MainTex_TexelSize.y * halfScaleCeil);
// 深度
float depth0 = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, bottomLeftUV).r;
float depth1 = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, topRightUV).r;
float depth2 = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, bottomRightUV).r;
float depth3 = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, topLeftUV).r;
// 深度法线
float3 normal0 = SAMPLE_TEXTURE2D(_CameraNormalsTexture, sampler_CameraNormalsTexture, bottomLeftUV).rgb;
float3 normal1 = SAMPLE_TEXTURE2D(_CameraNormalsTexture, sampler_CameraNormalsTexture, topRightUV).rgb;
float3 normal2 = SAMPLE_TEXTURE2D(_CameraNormalsTexture, sampler_CameraNormalsTexture, bottomRightUV).rgb;
float3 normal3 = SAMPLE_TEXTURE2D(_CameraNormalsTexture, sampler_CameraNormalsTexture, topLeftUV).rgb;
float3 viewNormal = normal0 * 2 - 1;
float NdotV = 1 - dot(viewNormal, -i.viewSpaceDir);
float normalThreshold01 = saturate((NdotV - _DepthNormalThreshold) / (1 - _DepthNormalThreshold));
float normalThreshold = normalThreshold01 * _DepthNormalThresholdScale + 1;
float depthThreshold = _DepthThreshold * depth0 * normalThreshold;
float depthFiniteDifference0 = depth1 - depth0;
float depthFiniteDifference1 = depth3 - depth2;
float edgeDepth = sqrt(pow(depthFiniteDifference0, 2) + pow(depthFiniteDifference1, 2)) * 100;
edgeDepth = edgeDepth > depthThreshold ? 1 : 0;
// 法线深度
float3 normalFiniteDifference0 = normal1 - normal0;
float3 normalFiniteDifference1 = normal3 - normal2;
float edgeNormal = sqrt(dot(normalFiniteDifference0, normalFiniteDifference0) + dot(normalFiniteDifference1, normalFiniteDifference1));
edgeNormal = edgeNormal > _NormalThreshold ? 1 : 0;
// 合并输出
float edge = max(edgeDepth, edgeNormal);
// 边缘颜色
float4 edgeColor = float4(_Color.rgb, _Color.a * edge);
float4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
return alphaBlend(edgeColor, color);
}
ENDHLSL
}
}
} Render
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
public class Outlint : ScriptableRendererFeature
{
[System.Serializable]
public class Settings
{
public RenderPassEvent renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing;
public Shader shader;
}
public Settings settings = new Settings();
OutlintPass outlintPass; // 定义我们创建出Pass
public override void Create()
{
this.name = "Outlint"; // 模糊渲染的名字
outlintPass = new OutlintPass(RenderPassEvent.BeforeRenderingPostProcessing, settings.shader); // 初始化 我们的渲染层级和Shader
}
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
renderer.EnqueuePass(outlintPass);
}
}
public class OutlintPass : ScriptableRenderPass
{
static readonly string RenderTag = "Post Effects"; // 设置渲染标签
OutlintVolume outlintvolume; // 定义组件类型
Material biltmaterial; // 后处理材质
public OutlintPass(RenderPassEvent evt, Shader biltshader)
{
renderPassEvent = evt;
var shader = biltshader;
if (shader == null)
{
Debug.LogError("没有指定Shader");
return;
}
biltmaterial = CoreUtils.CreateEngineMaterial(biltshader);
}
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
if (biltmaterial == null)
{
Debug.LogError("材质初始化失败");
return;
}
if (!renderingData.cameraData.postProcessEnabled)
{
return;
}
var stack = VolumeManager.instance.stack; // 传入 volume
outlintvolume = stack.GetComponent<OutlintVolume>(); // 获取到后处理组件
var cmd = CommandBufferPool.Get(RenderTag); // 渲染标签
Render(cmd, ref renderingData); // 调用渲染函数
context.ExecuteCommandBuffer(cmd); // 执行函数,回收。
CommandBufferPool.Release(cmd);
}
void Render(CommandBuffer cmd, ref RenderingData renderingData)
{
RenderTargetIdentifier source = renderingData.cameraData.renderer.cameraColorTarget; // 定义RT
RenderTextureDescriptor inRTDesc = renderingData.cameraData.cameraTargetDescriptor;
inRTDesc.depthBufferBits = 0; // 清除深度
var camera = renderingData.cameraData.camera; // 传入摄像机
Matrix4x4 clipToView = GL.GetGPUProjectionMatrix(camera.projectionMatrix, true).inverse;
biltmaterial.SetColor("_Color", outlintvolume.OutlintColor.value); // 获取value 组件的颜色
biltmaterial.SetMatrix("_ClipToView", clipToView); // 反向输出到Shader
biltmaterial.SetFloat("_Scale", outlintvolume.Scale.value);
biltmaterial.SetFloat("_DepthThreshold", outlintvolume.DepthThreshold.value);
biltmaterial.SetFloat("_NormalThreshold", outlintvolume.NormalThreshold.value);
biltmaterial.SetFloat("_DepthNormalThreshold", outlintvolume.DepthNormalThreshold.value);
biltmaterial.SetFloat("_DepthNormalThresholdScale", outlintvolume.DepthNormalThresholdScale.value);
int destination = Shader.PropertyToID("Temp1");
// 获取一张临时RT
cmd.GetTemporaryRT(destination, inRTDesc.width, inRTDesc.height, 0, FilterMode.Bilinear, RenderTextureFormat.DefaultHDR); //申请一个临时图像,并设置相机rt的参数进去
cmd.Blit(source, destination); // 设置后处理
cmd.Blit(destination, source, biltmaterial, 0); // 第二个Pass
}
} Volume
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
public class OutlintVolume : VolumeComponent, IPostProcessComponent
{
[Tooltip("边缘颜色")]
public ColorParameter OutlintColor = new ColorParameter(Color.white);
[Tooltip("边缘检测大小")]
public ClampedFloatParameter Scale = new ClampedFloatParameter(1f, 0f, 10f);
[Tooltip("深度")]
public ClampedFloatParameter DepthThreshold = new ClampedFloatParameter(0.2f, 0f, 10f);
[Tooltip("法线深度")]
public ClampedFloatParameter NormalThreshold = new ClampedFloatParameter(0.4f, 0f, 1f);
public ClampedFloatParameter DepthNormalThreshold = new ClampedFloatParameter(0.5f, 0f, 1f);
public ClampedFloatParameter DepthNormalThresholdScale = new ClampedFloatParameter(7f, 0f, 10f);
public bool IsActive() => Scale.value > 0;
public bool IsTileCompatible() => false;
}
实现后处理描边,比较复杂的地方是在Shader阶段,需要的数据很对,主要是获取深度和深度法线的计算方式,
深度获取记得开始管线

深度法线,我们使用SSAO系统给我们提供好的,

计算视角,需要把视角方向转换到屏幕空间进行计算,那一部分比较复杂,主要是 一个是处理UV,一个是处理使用顶点转换到屏幕空间。
卡通渲染之描边技术的实现(URP) - 知乎 (zhihu.com)
URP/LWRP Shader实现描边效果_danad的博客-CSDN博客_urp 描边
Unity Outline Shader Tutorial - Roystan