UnityUGUI镂空效果总结

前言

        果然只要在行业里足够长,重复的轮子总是要造的。最近刚好公司让我去新手引导,既然是新手引导,那么在UI上就需要一种镂空的效果。网上到是有着各种各样的方法,但是实现起来都有些复杂,甚至大多数都不符合我的项目需求。但是既然我都看了一遍,那就总结一下,顺便记录下我的自己的方案。

使用环境

    Windows11
    Unity 2022.3.52.f1c1
    Universal RP 14.0.11

使用渲染模版造就镂空效果

        网络很大一类方向就是这种使用渲染模版造就镂空效果,这种方案的原理和UGUI Mask相似。不同的是Mask是掩盖,而这种方案是镂空。我并没有找到对应的实现,但是按照思路我自己做了一个,这也是我这次项目正在使用的方案。

自定义Mask + 自定义Image

        阅读Mask源码后,我们可以发现,最重要的函数就是以下两个:IsRaycastLocationValidGetModifiedMaterial。第一个是用来判断射线范围,第二个则是用来开进行材质修改的。同时Image也使用了GetModifiedMaterial来进行材质修改。对于IsRaycastLocationValid的修改并不困难,我们之间使用Mask原先的实现,但是我们增加了是否为镂空区域的判断。如果当前是镂空区域,则Mask实现的相反值。为了能尽量搭配Mask,我并没有对GetModifiedMaterial函数进行改造。

        但是Image中的GetModifiedMaterial函数,我进行了修改了。原本的实现是如果有Mask,则只和此Mask对应的模板值上进行渲染。既然需求是要镂空,那么我们只需要将是自定义Mask的模板值外的信息进行渲染就好了。源码如下:

自定义Mask:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using UnityEngine;
using UnityEngine.UI;

namespace UGUIHollowOut
{
public class SelfUIMask : Mask
{
public bool isHollowOut = true;

public override bool IsRaycastLocationValid(Vector2 sp, Camera eventCamera)
{
return isHollowOut ^ base.IsRaycastLocationValid(sp, eventCamera);
}
}
}

因为UGUI对Mask做了额外的编辑器显示,因此我们的SelfUIMask也会被影响,从而无法对isHollowOut进行修改。于是我重写了对应的编辑器显示,源码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
using UnityEditor;
using UnityEngine.UI;

namespace UGUIHollowOut
{
[CustomEditor(typeof(SelfUIMask), true)]
[CanEditMultipleObjects]
public class SelfUIMaskEditor : Editor
{
SerializedProperty m_ShowMaskGraphic;
SerializedProperty m_IsHollowOut;

protected virtual void OnEnable()
{
m_ShowMaskGraphic = serializedObject.FindProperty("m_ShowMaskGraphic");
m_IsHollowOut = serializedObject.FindProperty("isHollowOut");
}

public override void OnInspectorGUI()
{
var graphic = (target as Mask).GetComponent<Graphic>();

if (graphic && !graphic.IsActive())
EditorGUILayout.HelpBox("Masking disabled due to Graphic component being disabled.", MessageType.Warning);

serializedObject.Update();
EditorGUILayout.PropertyField(m_ShowMaskGraphic);
EditorGUILayout.PropertyField(m_IsHollowOut);
serializedObject.ApplyModifiedProperties();
}
}
}

自定义Image:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.UI;

namespace UGUIHollowOut
{
public class SelfUIImage : Image
{
public override Material GetModifiedMaterial(Material baseMaterial)
{
var toUse = baseMaterial;
// 只判断其父是否为自定义遮罩
SelfUIMask selfUIMask = GetComponentInParent<SelfUIMask>();
// 启用了且是需要镂空的,否则直接走原生Image的逻辑
if(selfUIMask != null && selfUIMask.IsActive() && selfUIMask.transform != this.transform && selfUIMask.isHollowOut)
{
// 为了搭配Mask,寻找离其最近的原生Mask
int closestOriginMask = 0;
if (m_ShouldRecalculateStencil)
{
var rootCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
m_StencilValue = maskable ? MaskUtilities.GetStencilDepth(transform, rootCanvas) : 0;
closestOriginMask = GetClosestOriginMask(transform, rootCanvas);
m_ShouldRecalculateStencil = false;
}

int val = 1 << m_StencilValue - 1;
var compareFun = CompareFunction.Greater;

// 如果找寻到原生Mask,那就直接走对应Mask的模板值
if (closestOriginMask != 0 && m_StencilValue != closestOriginMask)
{
compareFun = CompareFunction.Equal;
val = 1 << (m_StencilValue - closestOriginMask) - 1;
Debug.Log(val);
}

var maskMat = StencilMaterial.Add(toUse, val, StencilOp.Keep, compareFun, ColorWriteMask.All);
StencilMaterial.Remove(m_MaskMaterial);
m_MaskMaterial = maskMat;
toUse = m_MaskMaterial;
}
else toUse = base.GetModifiedMaterial(baseMaterial);
return toUse;
}

public int GetClosestOriginMask(Transform transform, Transform stopAfter)
{
var depth = 0;
if (transform == stopAfter)
return depth;

var t = transform.parent;
var components = ListPool<Mask>.Get();
while (t != null)
{
t.GetComponents<Mask>(components);
for (var i = 0; i < components.Count; ++i)
{
if (components[i] != null && components[i].MaskEnabled() && components[i].graphic.IsActive())
{
if(components[i].GetType().Equals(typeof(Mask)))
{
t = stopAfter;
break;
}
else
{
// 判断当前的自定义遮罩是否为Mask的效果
var selfMask = components[i] as SelfUIMask;
if(selfMask != null && !selfMask.isHollowOut)
{
t = stopAfter;
break;
}
}
++depth;
break;
}
}

if (t == stopAfter)
break;

t = t.parent;
}
ListPool<Mask>.Release(components);
return depth;
}
}

}

使用说明

        这个使用和Mask一致。只是使用SelfUIMask的地方,只是搭配SelfUIImage才能实现镂空效果。

优缺点

        优点:因为是魔改的Mask,与其他的方案相比,无论是渲染上的压力,还是在CPU计算上其消耗几乎等同于Mask。而且适应各种图案。

        缺点:因为是魔改的Mask,所以在使用上和Mask一样,在图形边缘会产生严重的锯齿。这是因为模版测试并不会产生中间值,要么渲染,要么不渲染。而解决方案也和Mask一样,就是在外围用其他东西遮挡。除此之外对于镂空区域的检测也算不上准确。对于需要多个镂空区域的UI,这种方案的实现会非常复杂。我能想到的方案就是就是用大图将需要镂空的地方显现出来。这种方案除了在渲染上会消耗大量资源,对于镂空区域就是不准确的。而其和Mask的搭配也存在一些问题。除此之外,其不支持嵌套使用。

        实际上,如果只是做新手引导的操作,那么这种方案就已经足够了。因为新手引导的操作,一般都是只需要单一区域镂空,并且黑色底图覆盖全屏。总而言之,如果要求并不复杂,那么这个就完全可以满足你的需求了。

PS:之前写的时候没有感觉,但是回顾的时候才发现这个问题。实际上对于我对Mask几乎没有改动,本身镂空区域的判断就是存在问题。既然如此还不如用一个简单的实现打个“标签”就好了。但是我懒不想改了。我自己公司项目也这样操作了。

额外说明

        如果你的项目并不需要一个Mask + 镂空区域,那么你还是可以简化一下代码,自定义Image中的函数可以简化为下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public override Material GetModifiedMaterial(Material baseMaterial)
{
var toUse = baseMaterial;
// 只判断其父是否为自定义遮罩
SelfUIMask selfUIMask = GetComponentInParent<SelfUIMask>();
// 启用了且是需要镂空的,否则直接走原生Image的逻辑
if(selfUIMask != null && selfUIMask.IsActive() && selfUIMask.transform != this.transform && selfUIMask.isHollowOut)
{
// 只算一个SelfUIMask
var maskMat = StencilMaterial.Add(toUse, 1, StencilOp.Keep, CompareFunction.Greater, ColorWriteMask.All);
StencilMaterial.Remove(m_MaskMaterial);
m_MaskMaterial = maskMat;
toUse = m_MaskMaterial;
}
else toUse = base.GetModifiedMaterial(baseMaterial);
return toUse;
}

使用额外的贴图遮罩进行镂空的渲染

        我们将要镂空的物体提取出来,然后将他们渲染到一张遮罩图。然后我们渲染这张图就可以达到镂空的效果。这个我在网上并没有找到对应的URP实现的,所以下面的代码也是我个人做的实现。我必须在此提前说明,这部分的实现,我并没有用在工程中。对应的实现代码就是为了这个思想而去做的实现,我实在技术有限做不出更好的做法。

        我们可以使用URP的Render Feather来做对应的遮罩图渲染。我直接使用单例的方法将想要镂空的物体传给Render Feather,然后让Render Feather重新渲染物体到遮罩图上。实际上,我觉得我的做法并不好,虽然它确实可以做到镂空的效果,但是其耗费性能实现起来也不算得上优雅。在实际工程中,你根据需要进行修改。

        对于UI我们需要一个额外的摄像机使其渲染到RT上。关于UI的渲染,我对其了解不多,因此我并不知道如何将其对应的UI物体进行单独渲染到RT的操作。因此我选择使用额外的相机进行对应的操作。

实现

        因为我们只是想要对应物体所占屏幕的大小,我们可以选择将物体本身材质重新渲染一遍,或者是使用一个简单的材质来进行渲染。我的想法是让一开始RT中的透明度为0。对于UI,我保留了其渲染方式,而场景中的物体,我则是简单的进行处理。最终我们需要镂空的地方,其透明度大于0。最后我使用RawImage来进行渲染,对RT中的透明度进行反向操作

物体的Shader:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
Shader "UGUIHollowOut/ObjHollowOut"
{
Properties
{
}
SubShader
{
Tags
{
"RenderType" = "Opaque"
"RenderPipeline" = "UniversalPipeline"
"Queue" = "Geometry"
}

Pass
{
Name "ObjHollowOut"
Tags { "LightMode" = "UniversalForward" }

ZWrite On
ZTest LEqual
Cull Back

HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

struct Attributes
{
float4 positionOS : POSITION;
};

struct Varyings
{
float4 positionHCS : SV_POSITION;
};

Varyings vert(Attributes input)
{
Varyings output;
VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
output.positionHCS = vertexInput.positionCS;
return output;
}

half4 frag(Varyings input) : SV_Target
{
return half4(1, 1, 1, 1);
}
ENDHLSL
}
}
FallBack "Hidden/Universal Render Pipeline/FallbackError"
}

RawImage上的Shader:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
Shader "UGUIHollowOut/HollowOutUI"
{
Properties
{
[PerRendererData] _MainTex("Sprite Texture", 2D) = "white" {}
_Color("Tint", Color) = (1,1,1,1)

_StencilComp("Stencil Comparison", Float) = 8
_Stencil("Stencil ID", Float) = 0
_StencilOp("Stencil Operation", Float) = 0
_StencilWriteMask("Stencil Write Mask", Float) = 255
_StencilReadMask("Stencil Read Mask", Float) = 255

_ColorMask("Color Mask", Float) = 15

[Toggle(UNITY_UI_ALPHACLIP)] _UseUIAlphaClip("Use Alpha Clip", Float) = 0
}

SubShader
{
Tags
{
"Queue" = "Transparent"
"IgnoreProjector" = "True"
"RenderType" = "Transparent"
"PreviewType" = "Plane"
"CanUseSpriteAtlas" = "True"
}

Stencil
{
Ref[_Stencil]
Comp[_StencilComp]
Pass[_StencilOp]
ReadMask[_StencilReadMask]
WriteMask[_StencilWriteMask]
}

Cull Off
Lighting Off
ZWrite Off
ZTest[unity_GUIZTestMode]
Blend SrcAlpha OneMinusSrcAlpha
ColorMask[_ColorMask]

Pass
{
Name "Default"

HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma target 2.0

#include "UnityCG.cginc"
#include "UnityUI.cginc"

#pragma multi_compile_local _ UNITY_UI_CLIP_RECT
#pragma multi_compile_local _ UNITY_UI_ALPHACLIP

struct appdata_t
{
float4 vertex : POSITION;
float4 color : COLOR;
float2 texcoord : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct v2f
{
float4 vertex : SV_POSITION;
fixed4 color : COLOR;
float2 texcoord : TEXCOORD0;
float4 worldPosition : TEXCOORD1;
UNITY_VERTEX_OUTPUT_STEREO
};

sampler2D _MainTex;
fixed4 _Color;
fixed4 _TextureSampleAdd;
float4 _ClipRect;
float4 _MainTex_ST;

v2f vert(appdata_t v)
{
v2f OUT;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(OUT);
OUT.worldPosition = v.vertex;
OUT.vertex = UnityObjectToClipPos(OUT.worldPosition);
OUT.texcoord = TRANSFORM_TEX(v.texcoord, _MainTex);
OUT.color = v.color * _Color;
return OUT;
}

fixed4 frag(v2f IN) : SV_Target
{
half4 texColor = (tex2D(_MainTex, IN.texcoord) + _TextureSampleAdd);

half invertedAlpha = 1.0 - texColor.a;
half3 invertedRGB = half3(invertedAlpha, invertedAlpha, invertedAlpha);
half4 color = IN.color * half4(invertedRGB, invertedAlpha);

#ifdef UNITY_UI_CLIP_RECT
color.a *= UnityGet2DClipping(IN.worldPosition.xy, _ClipRect);
#endif

#ifdef UNITY_UI_ALPHACLIP
clip(color.a - 0.001);
#endif

return color;
}
ENDHLSL
}
}
}

为了让Render Feather可以知道对应的数据,我使用了单例来进行数据的传输。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
using System.Collections.Generic;
using UnityEngine;

namespace UGUIHollowOut
{
[ExecuteAlways]
/// <summary>
/// 镂空遮罩纹理管理器 —— 单例
/// 收集需要重绘的物体,通过 URP RenderPass 渲染到共享 RenderTexture
/// </summary>
public class HollowOutMaskTexMgr : MonoBehaviour
{
public static HollowOutMaskTexMgr Instance { get; private set; }

[Tooltip("所有要重新绘制到遮罩贴图的物体")]
public List<GameObject> objectsToRender = new List<GameObject>();

[Tooltip("目标 RenderTexture")]
public RenderTexture targetRT;

[Tooltip("用于绘制 UI 物体的摄像机")]
public Camera uiCamera;

[Tooltip("用于绘制场景物体的主摄像机")]
public Camera mainSceneCamera;

[SerializeField]
private bool excute;

private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
uiCamera.enabled = false;

Instance = this;
}

private void Update()
{
if(excute)
{
// 移除UI元素,因为UI有额外的相机做渲染了
for(int i = objectsToRender.Count - 1; i > -1; --i)
{
if (IsUI(objectsToRender[i]))
{
uiCamera.enabled = true;
objectsToRender.RemoveAt(i);
}
}
Excute();
excute = false;
}
}

/// <summary>
/// 每帧 LateUpdate 时,如果列表非空,将数据共享给 RenderPass
/// URP 渲染管线在 LateUpdate 之后执行,RenderPass 将读取这些静态数据
/// </summary>
public void Excute()
{
if (objectsToRender.Count > 0)
{
HollowOutRenderPass.SharedObjectsToRender = objectsToRender;
HollowOutRenderPass.SharedRT = targetRT;
HollowOutRenderPass.SharedUICamera = uiCamera;
HollowOutRenderPass.SharedMainCamera = mainSceneCamera;
}
}

/// <summary>添加单个物体到渲染列表</summary>
public void AddObject(GameObject obj)
{
if (obj != null && !objectsToRender.Contains(obj))
{
if("HollowOutUI".Equals(LayerMask.LayerToName(obj.layer)))
uiCamera.enabled = true;
else objectsToRender.Add(obj);
}
}

private bool IsUI(GameObject obj)
{
return "HollowOutUI".Equals(LayerMask.LayerToName(obj.layer));
}
}
}

对应的Render Feather的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
using UnityEngine.Rendering.Universal;

namespace UGUIHollowOut
{
/// <summary>
/// URP Render Feature 入口
/// 将 HollowOutRenderPass 注入到 URP 管线 BeforeRenderingOpaques 阶段
/// 需在 Editor 中手动添加到 URP Renderer Asset 的 Renderer Features 列表
/// </summary>
public class HollowOutRenderFeature : ScriptableRendererFeature
{
private HollowOutRenderPass hollowOutPass;

public override void Create()
{
hollowOutPass = new HollowOutRenderPass();
hollowOutPass.renderPassEvent = RenderPassEvent.BeforeRenderingOpaques;
}

public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
if (hollowOutPass != null)
{
if(HollowOutRenderPass.SharedMainCamera != null && renderingData.cameraData.camera.Equals(HollowOutRenderPass.SharedMainCamera))
{
renderer.EnqueuePass(hollowOutPass);
}
}
}
}
}

这里我将Pass放在了主摄像机渲染之前,因为在我的设定中HollowOutMaskTexMgruiCamera(这个是用做镂空的摄像机)一定会先渲染,然后是主摄像机,最后是UI摄像机(如果有的话)。uiCamera先渲染后已经把UI中需要镂空的部分渲染到了RT中,为了更好的效果,在接下来主摄像机渲染中,这个RT需要先被渲染来完。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

namespace UGUIHollowOut
{
/// <summary>
/// 镂空遮罩渲染通道
/// 每帧从 HollowOutMaskTexMgr 读取共享数据,清空 RT 后分两步绘制:
/// 1. UI 层物体 → 切层到 HollowOut → UI 摄像机剔除 + context.DrawRenderers → 恢复层
/// 2. 非 UI 层物体 → ObjHollowOut 着色器 + 主摄像机矩阵,逐物体绘制
///
/// OnCameraSetup 中通过 ConfigureTarget 将 pass 渲染目标绑定到 SharedRT,
/// 确保 context.DrawRenderers 也绘制到正确的 RT。
/// </summary>
public class HollowOutRenderPass : ScriptableRenderPass
{
// ---- 由 HollowOutMaskTexMgr.LateUpdate 写入的共享数据 ----
public static List<GameObject> SharedObjectsToRender;
public static RenderTexture SharedRT;
public static Camera SharedUICamera;
public static Camera SharedMainCamera;

private Material objHollowOutMaterial;
private RTHandle targetHandle;

public HollowOutRenderPass()
{
renderPassEvent = RenderPassEvent.AfterRenderingTransparents;

var shader = Shader.Find("UGUIHollowOut/ObjHollowOut");
if (shader != null)
{
objHollowOutMaterial = new Material(shader);
objHollowOutMaterial.hideFlags = HideFlags.HideAndDontSave;
}
else
{
Debug.LogError("[HollowOutRenderPass] 找不到着色器 UGUIHollowOut/ObjHollowOut,请确认 .shader 文件存在");
}
}

public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
{
// 将整个 pass 的渲染目标绑定到 SharedRT,
// 确保 context.DrawRenderers(用于 UI 的 CanvasRenderer)也画到正确的 RT
if (SharedRT != null)
{
if (targetHandle == null || targetHandle.rt != SharedRT)
{
targetHandle?.Release();
targetHandle = RTHandles.Alloc(SharedRT);
}
ConfigureTarget(targetHandle);
}
}

public override void OnCameraCleanup(CommandBuffer cmd)
{
// 不在这里高频释放 RTHandle,外部在 SharedRT 变化时通过 DisposeHandle 回收
}

/// <summary>
/// 当 SharedRT 被替换时,由外部调用释放旧的 RTHandle
/// </summary>
public void DisposeHandle()
{
targetHandle?.Release();
targetHandle = null;
}

public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
if (SharedObjectsToRender == null || SharedObjectsToRender.Count == 0)
return;
if (SharedRT == null)
return;
if (objHollowOutMaterial == null)
return;

int uiLayerIndex = LayerMask.NameToLayer("UI");
if (uiLayerIndex < 0)
{
uiLayerIndex = 5;
}
int uiLayerMask = 1 << uiLayerIndex;

CommandBuffer cmd = CommandBufferPool.Get("HollowOutPass");

// 清空 RT
cmd.SetRenderTarget(SharedRT);
// 没有UI相机进行渲染的时候,清空
if(SharedUICamera != null && SharedUICamera.enabled)
{
SharedUICamera.enabled = false;
cmd.Blit(SharedRT, SharedUICamera.targetTexture);
}
else cmd.ClearRenderTarget(true, true, new Color(0, 0, 0, 0));
context.ExecuteCommandBuffer(cmd);
cmd.Clear();

if (SharedMainCamera != null)
{
cmd.SetViewProjectionMatrices(
SharedMainCamera.worldToCameraMatrix,
SharedMainCamera.projectionMatrix
);
context.ExecuteCommandBuffer(cmd);
cmd.Clear();

foreach (var go in SharedObjectsToRender)
{
if (go == null)
continue;
if ((uiLayerMask & (1 << go.layer)) != 0)
continue;

var renderer = go.GetComponent<Renderer>();
if (renderer == null || !renderer.enabled)
continue;

cmd.DrawRenderer(renderer, objHollowOutMaterial);
}
context.ExecuteCommandBuffer(cmd);
cmd.Clear();
}

SharedObjectsToRender.Clear();
SharedObjectsToRender = null;
CommandBufferPool.Release(cmd);
}
}
}

为了在性能上有所优化,我这边只对RT进行一次渲染。如果你的物体会进行频繁的移动,你可以修改代码,通过不清除,不隐藏相机来保证渲染效果。

使用说明

        找到对应的项目的URP设置,如果你找不到可以看《Unity查找URP设置的方法》。然后在对应的设置下添加HollowOutRenderFeature

        创建一个RT,这个RT的大小由你来定最好要和你渲染的屏幕成比例,这关乎到后续UI的渲染。比如我的屏幕是1920 x 1080,我设置的RT大小为960 x 540。为了削减性能压力,所以我将对应的渲染的RT长宽设定为目标屏幕大小的一半。

        在场景中创建一个空物体,然后在空物体上挂载HollowOutMaskTexMgr。我们将刚刚创建的RT挂载到HollowOutMaskTexMgrtargetRT上。对需要镂空的物体添加到HollowOutMaskTexMgrobjectsToRender中(也可以用代码添加)。

        关于在UI中需要镂空的区域,我们需要创建一个新的摄像机(Camera),为了后续方便之后称这个相机为镂空相机。为了保证渲染顺序,我将镂空摄像机的优先级调高。我们设定一个一个新的层(Layer),这里我将其命名为HollowOutUI,主摄像机和UI摄像机都不能渲染该Layer。只有我们创建出来的镂空相机才可以渲染。你可以通过修改相机中的Culling Mask来控制相机的渲染。之后我们将镂空相机的Output Texture设定为我们创建的RT。并将镂空相机挂载到HollowOutMaskTexMgrUi Camera上。完成后,我们设定一个新的画布(Canvas)。这个画布下的物体的层都是HollowOutUI。因为要设定镂空相机专门负责渲染UI上的镂空区域,所以它的大部分设置最好和UI摄像机一样。

        在UI上添加一个RawImage,并将我们创建的RT赋值给他。创建一个材质,这个材质用我们上面的所述的RawImage上的Shader。将这个创建出来的材质赋给RawImageRawImage最好要覆盖整个画布,否则你或许需要修改Shader来适配。

        最后点击HollowOutMaskTexMgrexcute,或者手动调用其Excute函数来实现。

优缺点

        优点:与其他的方案相比,这种方案的效果不错,且它不仅仅只作用于UI物体。

        缺点:因为要重新渲染一遍物体,且需要额外的图片,因此性能上是有压力的。如上所述,其操作看起来也算是复杂。

额外说明

        我认为这种方案更适合于在场景中做镂空效果。在UI上的效果用前面说的方案会更好一点。如果项目上实在需要,你可以使用两种方案结合的做法。

使用Mesh进行物理层次上的镂空

        这个方案的想法,我是从《Unity UGUI引导镂空效果,添加背景遮罩带内倒角镂空》中想到的。其想法就是将想要的区域进行三角化,然后渲染得到的区域,这样自然就可以达到镂空的效果。为了可以较好的实现三角化操作,这里我使用了earcut算法进行三角化。下面的三角化代码是我用AI将这个https://github.com/oberbichler/Cutear中的代码进行转换得到的。我自己测试下来,这个算法的三角化效果还是不错的。至少不是特别奇怪的模型都是可以的。

实现

        具体的想法就是传点位,然后使用Earcut进行三角化。通关三角化的数据将Mesh进行重建。最终实现镂空效果。

        先是Earcut的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934

/*
* Copyright (c) 2018-2026, Thomas Oberbichler
*
* Permission to use, copy, modify, and/or distribute this software for any purpose
* with or without fee is hereby granted, provided that the above copyright notice
* and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
* REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
* FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
* INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
* OF USE, DATA, OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
* TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
* THIS SOFTWARE.
*/

using System;
using System.Collections.Generic;
using UnityEngine;

namespace Earcut
{
public static class Earcut
{
[ThreadStatic]
private static List<Node> _nodeCache;

[ThreadStatic]
private static int _nodeCacheIndex;

[ThreadStatic]
private static List<Node> _queueCache;

public static List<int> Tessellate(ReadOnlySpan<double> data, ReadOnlySpan<int> holeIndices, int dim = 2)
{
var hasHoles = holeIndices.Length > 0;
var outerLen = hasHoles ? holeIndices[0] * dim : data.Length;

var outerNode = LinkedList(data, 0, outerLen, dim, true);

int estimatedIndices = Math.Max(0, 3 * (data.Length / dim - 2));
var triangles = new List<int>(estimatedIndices);

if (outerNode == null || outerNode.next == outerNode)
{
return triangles;
}

try
{
if (hasHoles)
{
outerNode = EliminateHoles(data, holeIndices, outerNode, dim);
}

double minX = 0, minY = 0, invSize = 0;
if (data.Length > 80 * dim)
{
minX = data[0];
minY = data[1];
double maxX = minX, maxY = minY;

for (int i = dim; i < outerLen; i += dim)
{
double x = data[i], y = data[i + 1];
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
}

invSize = Math.Max(maxX - minX, maxY - minY);
invSize = invSize != 0 ? 1.0 / invSize : 0;
}

EarcutLinked(outerNode, triangles, dim, minX, minY, invSize, 0);

return triangles;
}
finally
{
ClearCacheReferences();
}
}

public static List<int> Tessellate(double[] data, int[] holeIndices, int dim = 2)
{
return Tessellate(
new ReadOnlySpan<double>(data),
holeIndices != null ? new ReadOnlySpan<int>(holeIndices) : ReadOnlySpan<int>.Empty,
dim);
}

public static List<int> Tessellate(float[] data, int[] holeIndices, int dim = 2)
{
if (data == null || data.Length == 0)
return new List<int>();

int n = data.Length;
var doubleData = new double[n];
for (int i = 0; i < n; i++)
doubleData[i] = data[i];

return Tessellate(
new ReadOnlySpan<double>(doubleData),
holeIndices != null ? new ReadOnlySpan<int>(holeIndices) : ReadOnlySpan<int>.Empty,
dim);
}

public static List<int> Tessellate(Vector2[] vertices, int[] holeIndices)
{
if (vertices == null || vertices.Length == 0)
return new List<int>();

int n = vertices.Length;
var doubleData = new double[n * 2];
for (int i = 0; i < n; i++)
{
doubleData[i * 2] = vertices[i].x;
doubleData[i * 2 + 1] = vertices[i].y;
}

return Tessellate(
new ReadOnlySpan<double>(doubleData),
holeIndices != null ? new ReadOnlySpan<int>(holeIndices) : ReadOnlySpan<int>.Empty,
2);
}

public static List<int> Tessellate(Vector3[] vertices, int[] holeIndices)
{
if (vertices == null || vertices.Length == 0)
return new List<int>();

int n = vertices.Length;
var doubleData = new double[n * 2];
for (int i = 0; i < n; i++)
{
doubleData[i * 2] = vertices[i].x;
doubleData[i * 2 + 1] = vertices[i].y;
}

return Tessellate(
new ReadOnlySpan<double>(doubleData),
holeIndices != null ? new ReadOnlySpan<int>(holeIndices) : ReadOnlySpan<int>.Empty,
2);
}

public static double Deviation(double[] data, int[] holeIndices, List<int> triangles, int dim = 2)
{
if (triangles == null || triangles.Count == 0)
return 0;

var dataSpan = new ReadOnlySpan<double>(data);
var holeSpan = holeIndices != null ? new ReadOnlySpan<int>(holeIndices) : ReadOnlySpan<int>.Empty;

var hasHoles = holeSpan.Length > 0;
var outerLen = hasHoles ? holeSpan[0] * dim : dataSpan.Length;

var polygonArea = Math.Abs(SignedArea(dataSpan, 0, outerLen, dim));
if (hasHoles)
{
var len = holeSpan.Length;
for (var i = 0; i < len; i++)
{
var start = holeSpan[i] * dim;
var end = i < len - 1 ? holeSpan[i + 1] * dim : dataSpan.Length;
polygonArea -= Math.Abs(SignedArea(dataSpan, start, end, dim));
}
}

var trianglesArea = 0.0;
int triCount = triangles.Count;
for (var i = 0; i < triCount; i += 3)
{
var a = triangles[i] * dim;
var b = triangles[i + 1] * dim;
var c = triangles[i + 2] * dim;
trianglesArea += Math.Abs(
(dataSpan[a] - dataSpan[c]) * (dataSpan[b + 1] - dataSpan[a + 1]) -
(dataSpan[a] - dataSpan[b]) * (dataSpan[c + 1] - dataSpan[a + 1]));
}

return polygonArea == 0 && trianglesArea == 0
? 0
: Math.Abs((trianglesArea - polygonArea) / polygonArea);
}

private static Node LinkedList(ReadOnlySpan<double> data, int start, int end, int dim, bool clockwise)
{
Node last = null;

if (clockwise == (SignedArea(data, start, end, dim) > 0))
{
// 正向:从 start 到 end,步长 dim
for (int i = start; i < end; i += dim)
{
last = InsertNode(i / dim, data[i], data[i + 1], last);
}
}
else
{
for (int i = end - dim; i >= start; i -= dim)
{
last = InsertNode(i / dim, data[i], data[i + 1], last);
}
}

if (last != null && Equals(last, last.next))
{
RemoveNode(last);
last = last.next;
}

return last;
}

private static Node FilterPoints(Node start, Node end = null)
{
if (start == null) return start;
if (end == null) end = start;

var p = start;
bool again;
int loopCount = 0; // 防止无限循环的安全计数器

do
{
if (loopCount++ > 1000000) break;
again = false;

if (!p.steiner && (Equals(p, p.next) || Area(p.prev, p, p.next) == 0))
{
RemoveNode(p);
p = end = p.prev;
if (p == p.next) break; // 只剩最后一个点
again = true; // 重新检查(因为链表结构已变)
}
else
{
p = p.next;
}
} while (again || p != end);

return end;
}

private static void EarcutLinked(
Node ear, List<int> triangles, int dim,
double minX, double minY, double invSize, int pass, int depth = 0)
{
if (ear == null || depth > 1000) return;

if (pass == 0 && invSize != 0)
{
IndexCurve(ear, minX, minY, invSize);
}

var stop = ear;
int loopCount = 0;

while (ear.prev != ear.next) // 至少还有 3 个顶点
{
if (loopCount++ > 1000000) break; // 安全计数器

var prev = ear.prev;
var next = ear.next;

bool isEar = invSize != 0
? IsEarHashed(ear, minX, minY, invSize)
: IsEar(ear);

if (isEar)
{
triangles.Add(prev.i);
triangles.Add(next.i); // 交换:next 和 ear
triangles.Add(ear.i);

RemoveNode(ear);

ear = next.next;
stop = next.next;
continue;
}

ear = next;

if (ear == stop)
{
switch (pass)
{
case 0:
EarcutLinked(FilterPoints(ear), triangles, dim, minX, minY, invSize, 1, depth + 1);
break;
case 1:
ear = CureLocalIntersections(FilterPoints(ear), triangles);
EarcutLinked(ear, triangles, dim, minX, minY, invSize, 2, depth + 1);
break;
case 2:
SplitEarcut(ear, triangles, dim, minX, minY, invSize, depth + 1);
break;
}
break;
}
}
}

private static bool IsEar(Node ear)
{
var a = ear.prev;
var b = ear;
var c = ear.next;

if (Area(a, b, c) >= 0) return false;

var p = ear.next.next;
while (p != ear.prev)
{
if (PointInTriangle(a.x, a.y, b.x, b.y, c.x, c.y, p.x, p.y) &&
Area(p.prev, p, p.next) >= 0)
{
return false;
}
p = p.next;
}

return true;
}

private static bool IsEarHashed(Node ear, double minX, double minY, double invSize)
{
var a = ear.prev;
var b = ear;
var c = ear.next;

if (Area(a, b, c) >= 0) return false;

var minTX = Min(a.x, b.x, c.x);
var minTY = Min(a.y, b.y, c.y);
var maxTX = Max(a.x, b.x, c.x);
var maxTY = Max(a.y, b.y, c.y);

var minZ = ZOrder(minTX, minTY, minX, minY, invSize);
var maxZ = ZOrder(maxTX, maxTY, minX, minY, invSize);

var p = ear.prevZ;
var n = ear.nextZ;

while (p != null && p.z >= minZ && n != null && n.z <= maxZ)
{
if (p != ear.prev && p != ear.next &&
PointInTriangle(a.x, a.y, b.x, b.y, c.x, c.y, p.x, p.y) &&
Area(p.prev, p, p.next) >= 0)
return false;

p = p.prevZ;

if (n != ear.prev && n != ear.next &&
PointInTriangle(a.x, a.y, b.x, b.y, c.x, c.y, n.x, n.y) &&
Area(n.prev, n, n.next) >= 0)
return false;

n = n.nextZ;
}

while (p != null && p.z >= minZ)
{
if (p != ear.prev && p != ear.next &&
PointInTriangle(a.x, a.y, b.x, b.y, c.x, c.y, p.x, p.y) &&
Area(p.prev, p, p.next) >= 0)
return false;
p = p.prevZ;
}

while (n != null && n.z <= maxZ)
{
if (n != ear.prev && n != ear.next &&
PointInTriangle(a.x, a.y, b.x, b.y, c.x, c.y, n.x, n.y) &&
Area(n.prev, n, n.next) >= 0)
return false;
n = n.nextZ;
}

return true;
}

private static Node CureLocalIntersections(Node start, List<int> triangles)
{
var p = start;
int loopCount = 0;
do
{
if (loopCount++ > 1000000) break;

var a = p.prev;
var b = p.next.next;

if (!Equals(a, b) &&
Intersects(a, p, p.next, b) &&
LocallyInside(a, b) && LocallyInside(b, a))
{
triangles.Add(a.i);
triangles.Add(b.i);
triangles.Add(p.i);

RemoveNode(p);
RemoveNode(p.next);

p = start = b;
}
p = p.next;
} while (p != start);

return FilterPoints(p);
}

private static void SplitEarcut(
Node start, List<int> triangles, int dim,
double minX, double minY, double invSize, int depth)
{
var a = start;
int outerLoopCount = 0;

do
{
if (outerLoopCount++ > 1000000) break;

var b = a.next.next;
int innerLoopCount = 0;

while (b != a.prev)
{
if (innerLoopCount++ > 1000000) break;

if (a.i != b.i && IsValidDiagonal(a, b))
{
var c = SplitPolygon(a, b);

a = FilterPoints(a, a.next);
c = FilterPoints(c, c.next);

EarcutLinked(a, triangles, dim, minX, minY, invSize, 0, depth + 1);
EarcutLinked(c, triangles, dim, minX, minY, invSize, 0, depth + 1);
return;
}
b = b.next;
}
a = a.next;
} while (a != start);
}

private static Node EliminateHoles(
ReadOnlySpan<double> data, ReadOnlySpan<int> holeIndices,
Node outerNode, int dim)
{
_queueCache ??= new List<Node>();
_queueCache.Clear();

var len = holeIndices.Length;

for (var i = 0; i < len; i++)
{
var start = holeIndices[i] * dim;
var end = i < len - 1 ? holeIndices[i + 1] * dim : data.Length;
var list = LinkedList(data, start, end, dim, false);

if (list != null)
{
if (list == list.next)
{
list.steiner = true;
}
_queueCache.Add(GetLeftmost(list));
}
}

_queueCache.Sort(CompareXYSlope);

for (var i = 0; i < _queueCache.Count; i++)
{
outerNode = EliminateHole(_queueCache[i], outerNode);
}

_queueCache.Clear();
return outerNode;
}

private static int CompareXYSlope(Node a, Node b)
{
int res = a.x.CompareTo(b.x);
if (res == 0)
{
res = a.y.CompareTo(b.y);
if (res == 0)
{
double aSlope = (a.next.y - a.y) / (a.next.x - a.x);
double bSlope = (b.next.y - b.y) / (b.next.x - b.x);
res = aSlope.CompareTo(bSlope);
}
}
return res;
}

private static Node EliminateHole(Node hole, Node outerNode)
{
var bridge = FindHoleBridge(hole, outerNode);
if (bridge == null) return outerNode;

var bridgeReverse = SplitPolygon(bridge, hole);

FilterPoints(bridgeReverse, bridgeReverse.next);
return FilterPoints(bridge, bridge.next);
}

private static Node FindHoleBridge(Node hole, Node outerNode)
{
var p = outerNode;
double hx = hole.x, hy = hole.y;
double qx = double.NegativeInfinity;
Node m = null;

if (Equals(hole, p)) return p;
int loopCount1 = 0;
do
{
if (loopCount1++ > 1000000) break;

if (Equals(hole, p.next)) return p.next;

if (hy <= p.y && hy >= p.next.y && p.next.y != p.y)
{

double x = p.x + (hy - p.y) * (p.next.x - p.x) / (p.next.y - p.y);

if (x <= hx && x > qx)
{
qx = x;
m = p.x < p.next.x ? p : p.next;
if (x == hx) return m;
}
}
p = p.next;
} while (p != outerNode);

if (m == null) return null;

var stop = m;
double mx = m.x, my = m.y;
double tanMin = double.PositiveInfinity;

p = m;
int loopCount2 = 0;

do
{
if (loopCount2++ > 1000000) break;

if (hx >= p.x && p.x >= mx && hx != p.x &&
PointInTriangle(hy < my ? hx : qx, hy, mx, my, hy < my ? qx : hx, hy, p.x, p.y))
{
double tan = Math.Abs(hy - p.y) / (hx - p.x);

if (LocallyInside(p, hole) &&
(tan < tanMin || (tan == tanMin && (p.x > m.x || (p.x == m.x && SectorContainsSector(m, p))))))
{
m = p;
tanMin = tan;
}
}

p = p.next;
} while (p != stop);

return m;
}

private static bool SectorContainsSector(Node m, Node p)
{
return Area(m.prev, m, p.prev) < 0 && Area(p.next, m, m.next) < 0;
}

private static void IndexCurve(Node start, double minX, double minY, double invSize)
{
var p = start;
do
{
if (p.z == null)
{
p.z = ZOrder(p.x, p.y, minX, minY, invSize);
}
p.prevZ = p.prev;
p.nextZ = p.next;
p = p.next;
} while (p != start);

// 断开 prevZ 链的循环结构,准备链式归并排序
p.prevZ.nextZ = null;
p.prevZ = null;

SortLinked(p);
}

private static Node SortLinked(Node list)
{
int numMerges;
int inSize = 1; // 初始段大小

do
{
var p = list;
Node e;
list = null;
Node tail = null;
numMerges = 0;

while (p != null)
{
numMerges++;
var q = p;

// 找到第一个长为 inSize 的段
int pSize = 0;
for (int i = 0; i < inSize; i++)
{
pSize++;
q = q.nextZ;
if (q == null) break;
}

int qSize = inSize;

// 归并两个段
while (pSize > 0 || (qSize > 0 && q != null))
{
if (pSize != 0 && (qSize == 0 || q == null || p.z <= q.z))
{
e = p;
p = p.nextZ;
pSize--;
}
else
{
e = q;
q = q.nextZ;
qSize--;
}

if (tail != null)
tail.nextZ = e;
else
list = e;

e.prevZ = tail;
tail = e;
}

p = q;
}

tail.nextZ = null;
inSize *= 2;
} while (numMerges > 1);

return list;
}

private static int ZOrder(double x, double y, double minX, double minY, double invSize)
{
// 归一化到 [0, 32767]
int intX = (int)(32767 * (x - minX) * invSize);
int intY = (int)(32767 * (y - minY) * invSize);

// 位交错(Morton 编码)
intX = (intX | (intX << 8)) & 0x00FF00FF;
intX = (intX | (intX << 4)) & 0x0F0F0F0F;
intX = (intX | (intX << 2)) & 0x33333333;
intX = (intX | (intX << 1)) & 0x55555555;

intY = (intY | (intY << 8)) & 0x00FF00FF;
intY = (intY | (intY << 4)) & 0x0F0F0F0F;
intY = (intY | (intY << 2)) & 0x33333333;
intY = (intY | (intY << 1)) & 0x55555555;

return intX | (intY << 1);
}

private static Node GetLeftmost(Node start)
{
var p = start;
var leftmost = start;
do
{
if (p.x < leftmost.x || (p.x == leftmost.x && p.y < leftmost.y))
leftmost = p;
p = p.next;
} while (p != start);
return leftmost;
}

private static bool PointInTriangle(
double ax, double ay, double bx, double by,
double cx, double cy, double px, double py)
{
return (cx - px) * (ay - py) - (ax - px) * (cy - py) >= 0 &&
(ax - px) * (by - py) - (bx - px) * (ay - py) >= 0 &&
(bx - px) * (cy - py) - (cx - px) * (by - py) >= 0;
}

private static bool IsValidDiagonal(Node a, Node b)
{
return a.next.i != b.i && a.prev.i != b.i && !IntersectsPolygon(a, b) &&
((LocallyInside(a, b) && LocallyInside(b, a) && MiddleInside(a, b) &&
(Area(a.prev, a, b.prev) != 0 || Area(a, b.prev, b) != 0)) ||
(Equals(a, b) && Area(a.prev, a, a.next) > 0 && Area(b.prev, b, b.next) > 0));
}

private static double Area(Node p, Node q, Node r)
{
return (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y);
}

private static bool Equals(Node p1, Node p2)
{
return p1.x == p2.x && p1.y == p2.y;
}

private static bool Intersects(Node p1, Node q1, Node p2, Node q2)
{
// 两个线段共享相同端点
if ((Equals(p1, q1) && Equals(p2, q2)) ||
(Equals(p1, q2) && Equals(p2, q1)))
return true;

return Area(p1, q1, p2) > 0 != Area(p1, q1, q2) > 0 &&
Area(p2, q2, p1) > 0 != Area(p2, q2, q1) > 0;
}

private static bool IntersectsPolygon(Node a, Node b)
{
var p = a;
do
{
if (p.i != a.i && p.next.i != a.i && p.i != b.i && p.next.i != b.i &&
Intersects(p, p.next, a, b))
{
return true;
}
p = p.next;
} while (p != a);

return false;
}

private static bool LocallyInside(Node a, Node b)
{
return Area(a.prev, a, a.next) < 0
? Area(a, b, a.next) >= 0 && Area(a, a.prev, b) >= 0
: Area(a, b, a.prev) < 0 || Area(a, a.next, b) < 0;
}

private static bool MiddleInside(Node a, Node b)
{
var p = a;
var inside = false;
double px = (a.x + b.x) / 2;
double py = (a.y + b.y) / 2;

do
{
if (((p.y > py) != (p.next.y > py)) && p.next.y != p.y &&
// 线性插值计算射线与边的交点 x 坐标
(px < (p.next.x - p.x) * (py - p.y) / (p.next.y - p.y) + p.x))
{
inside = !inside; // 翻转奇偶性
}

p = p.next;
} while (p != a);

return inside;
}

private static Node SplitPolygon(Node a, Node b)
{
var a2 = CreateNode(a.i, a.x, a.y);
var b2 = CreateNode(b.i, b.x, b.y);
var an = a.next;
var bp = b.prev;

a.next = b;
b.prev = a;

a2.next = an;
an.prev = a2;

b2.next = a2;
a2.prev = b2;

bp.next = b2;
b2.prev = bp;

return b2;
}

private static Node InsertNode(int i, double x, double y, Node last)
{
var p = CreateNode(i, x, y);

if (last == null)
{
p.prev = p;
p.next = p;
}
else
{
p.next = last.next;
p.prev = last;
last.next.prev = p;
last.next = p;
}
return p;
}

private static void RemoveNode(Node p)
{
p.next.prev = p.prev;
p.prev.next = p.next;

if (p.prevZ != null)
p.prevZ.nextZ = p.nextZ;
if (p.nextZ != null)
p.nextZ.prevZ = p.prevZ;
}

private static Node CreateNode(int i, double x, double y)
{
_nodeCache ??= new List<Node>();

if (_nodeCacheIndex < _nodeCache.Count)
{
// 池中有可用对象:取出并重置
var node = _nodeCache[_nodeCacheIndex];
node.Reset(i, x, y);
_nodeCacheIndex++;
return node;
}
else
{
// 池已耗尽:创建新对象
var node = new Node(i, x, y);
_nodeCache.Add(node);
_nodeCacheIndex++;
return node;
}
}

private static void ClearCacheReferences()
{
if (_nodeCache != null)
{
for (int i = 0; i < _nodeCacheIndex; i++)
{
_nodeCache[i].ClearReferences();
}
}
_nodeCacheIndex = 0;

if (_queueCache != null)
{
_queueCache.Clear();
}
}

private static double SignedArea(ReadOnlySpan<double> data, int start, int end, int dim)
{
double sum = 0;
for (int i = start, j = end - dim; i < end; i += dim)
{
sum += (data[j] - data[i]) * (data[i + 1] + data[j + 1]);
j = i;
}
return sum;
}

private static double Min(double a, double b, double c)
{
return a < b ? (a < c ? a : c) : (b < c ? b : c);
}

private static double Max(double a, double b, double c)
{
return a > b ? (a > c ? a : c) : (b > c ? b : c);
}

private sealed class Node
{
public int i; // 顶点索引
public double x, y; // 顶点坐标
public int? z; // Z-order Morton 码(null 表示未计算)
public Node prev, next; // 多边形链表
public Node prevZ, nextZ; // Z-order 排序链表
public bool steiner; // 是否为 Steiner 点

public Node(int i, double x, double y)
{
this.i = i;
this.x = x;
this.y = y;
this.z = null;
}

public void Reset(int i, double x, double y)
{
this.i = i;
this.x = x;
this.y = y;
this.prev = null;
this.next = null;
this.z = null;
this.prevZ = null;
this.nextZ = null;
this.steiner = false;
}

public void ClearReferences()
{
this.prev = null;
this.next = null;
this.prevZ = null;
this.nextZ = null;
}
}
}
}

        镂空代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

namespace UGUIHollowOut
{
[RequireComponent(typeof(CanvasRenderer))]
public class MeshHollowOut : Graphic
{
[Header("Transform 输入")]
[Tooltip("内部要挖空的地方,最好不要超过其对应的大小")]
public RectTransform[] points;

[Tooltip("每个镂空处结束的索引(指向 points 数组中洞结束的 Transform 索引),如果只有一个洞就不用填")]
public int[] holeIndices;

public bool update;

private Vector2 maxR, minR;

private Vector2[] vex;
private int[] tri;
[SerializeField]
private int triCount;

protected override void OnPopulateMesh(VertexHelper vh)
{
if(vex == null)
base.OnPopulateMesh(vh);
else
{
vh.Clear();
var lenX = maxR.x - minR.x;
var lenY = maxR.y - minR.y;
for (int i = 0; i < vex.Length; i ++)
{
Vector3 pos = vex[i];
pos.z = transform.position.z;
var vexInfo = UIVertex.simpleVert;
vexInfo.position = vex[i];
vexInfo.color = color;
vexInfo.uv0 = new Vector2 ((pos.x - minR.x) / lenX, (pos.y - minR.y) / lenY);


vh.AddVert(vexInfo);
}

for (int i = 0, j = 0; i < tri.Length && j < triCount; i += 3,j++)
{
vh.AddTriangle(tri[i], tri[i + 1], tri[i + 2]);
}
}
}

protected override void OnValidate()
{
if(update)
{
update = false;
UpdateInfo();
}
SetAllDirty();
}

private void EarcutFun()
{
if (points == null || points.Length < 3)
{
vex = null;
return;
}

// 收集所有有效 Transform 的 2D 坐标(忽略已销毁/null)

var vertexList = new List<Vector2>();

var rectT = this.GetComponent<RectTransform>();
maxR = rectT.rect.max;
minR = rectT.rect.min;
// 接入外围的点位
vertexList.Add(maxR);
vertexList.Add(new Vector2(minR.x,maxR.y));
vertexList.Add(minR);
vertexList.Add(new Vector2(maxR.x, minR.y));

for (int i = 0; i < points.Length; i++)
{
if (points[i] != null)
{
vertexList.Add(points[i].anchoredPosition);
}
}

if(vertexList.Count - 4 < 3)
{
vex = null;
return;
}

var validHoles = new List<int> { 4 };
if(holeIndices != null)
{
foreach (var i in holeIndices)
{
validHoles.Add(i + 4);
}
}

try
{
var triangles = Earcut.Earcut.Tessellate(vertexList.ToArray(), validHoles.ToArray());

if (triangles.Count < 3)
{
vex = null;
}
else
{
vex = vertexList.ToArray();
tri = triangles.ToArray();
}
}
catch(System.Exception e)
{
vex = null;
Debug.LogException(e);
}
}

public void UpdateInfo()
{
EarcutFun();
SetAllDirty();
}
}
}

使用方法

        在UI上物体添加一个MeshHollowOut组件,然后在组件上设置将你要镂空的点位放到points。镂空形状的点位需要是按顺时针的。holeIndices只有你需多个镂空点位的时候才需要填写。比如你有两个镂空点位,第一个镂空点位是从0到3,第二个镂空点位是从4到7,那么holeIndices就需要填写{4}。如果你这时候又要加新一个了,那么holeIndices就需要填写{4,8}。总而言之就是填写你每个镂空点位(除第一个外)开始的索引。然后组件上的点击update

优缺点

        优点:通过Mesh进行物理层次上的镂空,可以实现非常精确的镂空效果。并且不需要额外的相机或特殊材质。这UI遮罩上,因为本身就是使用原生UI,自然支持也是最好的。

        缺点:实现复杂,需要使用三角化算法来建立Mesh,且需要一定的计算量。镂空区域的范围不能离开外部区域,否则会出现错误的镂空效果。多个镂空区域之间不能互相重叠,否则上述的实现会出现计算错误。操作麻烦,遇到复杂的图像,需要的点位也多。PS:上面实现中,我并没有做点位置的处理。所以导致了点位设定的时候只能相对于附加点自身。

额外说明

        这种方法如果在本身镂空区域较为简单的情况下还算可以。但是这最多就只能是一个实验性质的方案,我也是刚好最近有用到耳切算法,在加上的确网上有人有类似的使用Mesh来做镂空效果的做法,我才加上去。

        理论上它确实可以做到场景和UI都进行镂空,但是问题就在于这需要进行一系列的顶点转换。我个人后面确实没什么动力去做了,因此只写了这个简单版本。希望这个方案也能给到你一些启发。

其他方案概述

重复覆盖达到镂空效果

        很多时候新手引导是想要凸显出某个UI,那实际上直接将这个UI在黑色遮罩背景上进行重复覆盖,在效果上就可以达到镂空的效果。

        优点:用原生的UI,完美适配遮罩等一系列UI操作。无需多余代码。

        缺点:需要额外的重复开销和逻辑重复书写,且只能作用于UI上。

拼接达到镂空效果

        如果形状简单,比如像是矩形。那么我们通过简单的UI拼接来实现镂空效果。

        优点:用原生的UI,完美适配遮罩等一系列UI操作。无需多余代码。

        缺点:需要额外的重复开销,且只能作用于UI上。拼接操作也很麻烦,处理不好会留出缝隙,或是留下重影。

总结

        我虽然介绍了这么多,但是我自己使用的方案其实也就只有第一个。其他的方案我都没有在具体的工程上使用过。但是我还是希望能给到你一些启发。

参考文章和项目

闲言碎语

        这个文章拖了好久,主要原因是我才写不久工作就忙起来了。还有一个原因是最近我还是非常焦虑的,做事情我都没什么动力。我现在也还没调整好,希望我之后可以调整好。