前言

游戏开发过程中经常会设计到时间的运算判定,比如开服时间,触发时间,倒计时时间等,所有的这些时间都要基于和服务器或网络对时后的网络时间为基准来计算。单纯采用本地客户端时间的话会导致通过修改本地客户端时间就能达到加速和调时间的目的。本章节通过深入学习网络对时,实现一套没有服务器也能正确网络对时的对时框架(如果有后端的话以后端时间对时为准即可,本人是出于不懂服务器开发所以才采用网络对时的方案),为未来自己的独立游戏的网络对时打基础。

需求

  1. 通过网络实现对时,确保本地计算用的时间是准确无误不会受本地调时间影响的

时间

UTC时间

在了解同步时间之前,让我们先来了解下什么是时间。

平时我们说的时间年月日时分秒,这个时间是从哪里来的了?为什么不同的国家有不同的时间了?为什么要区分时区了?

带着这些疑问来看以下这篇文章介绍:

网络时间同步是怎么实现的?怎样消除延迟带来的影响?

这里我直接跳到重要的理论知识和结论上来。

世界标准时间:

  1. 世界时:基于天文现象 + 钟表计时,永远与地球自转时间相匹配
  2. 国际原子时:基于原子钟计时,每一秒的周期完全等长且固定

原子时非常稳定,但世界时随着地球自转变慢,会越来越慢. 科学家通过「闰秒」的方式来矫正这个误差,从而实现世界时+国际原子时混合的方式得到准确的计时。

上面说的**基于原子时 + 世界时「协调」**得出的时间就是我们通常说的UTC时间。有了UTC标准时间,各个国家的时间就是基于UTC+-*的方式的出来的,也就是我们通常说的时区时间(e.g. 北京时间,巴黎时间……)。当我们开发多个国家的游戏的时候,为了确保时间的统一,这个时候我们就可以采用UTC时间作为时间标准,每个国家的时间戳计算标准都是统一的(不需要考虑时区问题)。

在了解了UTC(统一标准时间)的来路后,那么接下来就是本文的重点如何同步网络时间。

Unix时间戳

时间戳适用于表示一个时间从固定时间点到当前时间的总秒数(或微秒数不同编程语言有不同)。

而编程里常用的则是Unix时间戳。

Unix时间戳是从UTC1970年1月1日0时0分0秒起至现在的总秒数,不考虑闰秒。

Note:

  1. 时间戳没有时区之分
  2. Unix时间戳是从1970年1月1日0点0分0秒作为基准算起

网络对时

网络对时顾名思义需要通过网络进行对时访问,而网络的访问我们都知道会有网络延时,那么我们如何在网络延时的基础上做到正确的网络对时了?

前人已经为此提供了解决方案,那就是NTP(Network Time Protocol)(网络对时服务)

这里简单了解下NTP是如何做到网络时间同步的。

通过在网络报文上打「时间戳」的方式,然后配合计算网络延迟,从而修正本机的时间。

网络对时请求

根据图示可以计算出网络「传输延迟」,以及客户端与服务端的「时间差」:

网络延时 = (t4 - t1) - (t3 - t2)

时间差 = t2 - t1 - 网络延时 / 2

这个计算过程假设网络来回路径是对称的,并且时延相同。

可以看到通过包含发送,接收,返回和返回接收时间戳的方式,我们可以计算出网络延迟从而做到正确的同步的网络时间

为了确保逻辑正确,时间严格意义上是不允许倒退的。那通过NTP同步的时间会出现时光倒流的情况吗?

NTP为此提供了两种方式:

  1. ntpdate:一切以服务端时间为准,「强制修改」本机时间
  2. ntpd:采用「润物细无声」的方式修改本机时间,把时间差均摊到每次小的调整上,避免发生「时光倒流」

在了解了NTP的对时原理后,接下来让我们通过实战Socket网络请求利用NTP实现网络对时功能。

实战

在实现通过Socket访问网络请求时,先让我们了解一下NTP的报文格式:

NTP报文格式

了解了NTP的报文格式我们就能编写Socket代码请求访问获取对时相关信息:

对时NTP网址,我采用的是阿里云提供的地址:

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
/*
* Description: NTPHostConfig.cs
* Author: TonyTang
* Create Date: 2022/07/21
*/

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

/// <summary>
/// NTPHostConfig.cs
/// NTP网址配置
/// </summary>
public static class NTPHostConfig
{
/// <summary>
/// NTP网址列表
/// </summary>
public static List<string> NTPHostList = new List<string>
{
"ntp2.aliyun.com",
"ntp3.aliyun.com",
"ntp4.aliyun.com",
"ntp5.aliyun.com",
"ntp6.aliyun.com",
"ntp7.aliyun.com",
};
}
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
/*
* Description: NTPClient.cs
* Author: TonyTang
* Create Date: 2022/07/15
*/

using System;
using System.Collections;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using UnityEngine;

/// <summary>
/// NTPClient.cs
/// NTP客户端
/// </summary>
public class NTPClient : SingletonTemplate<NTPClient>
{
/// <summary>
/// 对时网络地址(直接传IP地址时无值)
/// </summary>
public string Host
{
get;
private set;
}

/// <summary>
/// 对时IP连接地址
/// </summary>
public IPEndPoint IPEnd
{
get;
private set;
}

/// <summary>
/// NTP连接端口号
/// </summary>
private const int PortNumber = 123;

/// <summary>
/// NTP Socket
/// </summary>
private Socket mNTPSocket;

/// <summary>
/// NTP请求数据
/// </summary>
private byte[] mNtpSendData;

/// <summary>
/// NTP接受数据
/// </summary>
private byte[] mNtpReceiveData;

/// <summary>
/// <summary>
/// 服务器接收客户端时间请求时间起始位置
/// </summary>
private const int ServerReceivedTimePos = 32;

/// <summary>
/// 服务器回复时间起始位置
/// </summary>
private const int ServerReplyTimePos = 40;

/// <summary>
/// UTC时间戳基准时间
/// </summary>
private readonly DateTime UTCBaseTime = new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc);

/// <summary>
/// NTP网址列表
/// </summary>
private List<string> mNTPHostList;

/// <summary>
/// 时间同步是否成功
/// </summary>
private bool IsTimeSyncSuccess;

public NTPClient()
{
mNtpSendData = new byte[48];
// Setting the Leap Indicator, Version Number and Mode values
// LI = 0 (no warning), VN = 3 (IPv4 only), Mode = 3 (Client Mode)
mNtpSendData[0] = 0x1B;
mNtpReceiveData = new byte[48];
IsTimeSyncSuccess = false;
}

/// <summary>
/// 设置NTP网址列表
/// </summary>
/// <param name="ntpHostList"></param>
public bool SetHostList(List<string> ntpHostList)
{
mNTPHostList = ntpHostList;
if (!HasHost())
{
Debug.LogError($"请勿设置空NTP网址列表!");
return false;
}
return true;
}

/// <summary>
/// 同步网络时间
/// </summary>
/// <returns></returns>
public bool SyncTime()
{
// 未来有服务器的话,改为和服务器对时
if(!HasHost())
{
Debug.Log($"没有有效NTP网址,对时失败!");
return false;
}
DateTime? syncDateTime = null;
for (int i = 0, length = mNTPHostList.Count; i < length; i++)
{
InitByHost(mNTPHostList[i]);
if (SyncTime(out syncDateTime))
{
IsTimeSyncSuccess = true;
TimeHelper.SetNowUTCTime((DateTime)syncDateTime);
return true;
}
}
IsTimeSyncSuccess = false;
Debug.LogError($"所有NTP地址都同步时间失败!");
return false;
}

/// <summary>
/// 网络对时是否成功
/// </summary>
/// <returns></returns>
public bool IsSyncTimeSuccess()
{
return IsTimeSyncSuccess;
}

/// <summary>
/// 初始化对时地址
/// </summary>
/// <param name="host"></param>
private bool InitByHost(string host)
{
Host = host;
IPEnd = null;
var ipAdresses = Dns.GetHostAddresses(Host);
if(ipAdresses != null && ipAdresses.Length > 0)
{
IPEnd = new IPEndPoint(ipAdresses[0], PortNumber);
return true;
}
else
{
Debug.LogError($"网络地址:{host}的IP解析错误!");
return false;
}
}

/// <summary>
/// 初始化对时IP地址
/// </summary>
/// <param name="ip"></param>
private bool InitByIP(string ip)
{
Host = null;
IPEnd = null;
var ipAdress = IPAddress.Parse(ip);
if (ipAdress != null)
{
IPEnd = new IPEndPoint(ipAdress, PortNumber);
return true;
}
else
{
Debug.LogError($"IP地址:{ip}解析错误!");
return false;
}
}

/// <summary>
/// 网络对时
/// </summary>
/// <param name="syncDateTime"></param>
/// <returns></returns>
private bool SyncTime(out DateTime? syncDateTime)
{
syncDateTime = null;
if (IPEnd != null)
{
try
{
mNTPSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
Debug.Log($"网址:{Host} IP地址:{IPEnd.ToString()}");
mNTPSocket.Connect(IPEnd);
mNTPSocket.ReceiveTimeout = 3000;
// 客户端发送时间
ulong clientSendTime = (ulong)DateTime.UtcNow.Millisecond;
Debug.Log($"客户端发送时间:{clientSendTime}");
mNTPSocket.Send(mNtpSendData);
Array.Clear(mNtpReceiveData, 0, mNtpReceiveData.Length);
var recceiveByteNumbers = mNTPSocket.Receive(mNtpReceiveData);
Debug.Log($"接受返回字节数:{recceiveByteNumbers}");
// 客户端接收时间
ulong clientReceiveTime = (ulong)DateTime.UtcNow.Millisecond;
Debug.Log($"客户端接收时间:{clientReceiveTime}");
mNTPSocket.Shutdown(SocketShutdown.Both);
// 服务器接受消息时间
var serverReceivedTime = GetMilliSeconds(mNtpReceiveData, ServerReceivedTimePos);
Debug.Log($"服务器接受消息时间:{serverReceivedTime}");
// 服务器返回消息时间
var serverReplyTime = GetMilliSeconds(mNtpReceiveData, ServerReplyTimePos);
Debug.Log($"服务器返回消息时间:{serverReplyTime}");
// 网路延时 = (客户端接收时间 - 客户端发送时间) - (服务器返回消息时间 - 服务器接受消息时间)
// 时间差 = 服务器接受消息时间 - 客户端发送时间 - 网络延时 / 2 = ((服务器接受消息时间 - 客户端发送时间) + (服务器返回消息时间 - 客户端接收时间)) / 2
// 当前同步服务器时间 = 客户端接收时间 + 时间差
var offsetTime = ((serverReceivedTime - clientSendTime) + (serverReplyTime - clientReceiveTime)) / 2;
var syncTime = clientReceiveTime + offsetTime;
syncDateTime = UTCBaseTime.AddMilliseconds(syncTime);
Debug.Log($"IP地址:{IPEnd.ToString()},当前同步UTC时间:{syncDateTime.ToString()}");
return true;
}
catch(SocketException e)
{
Debug.LogError($"IP地址:{IPEnd.ToString()}连接异常:{e.Message},ErrorCode:{e.ErrorCode}!");
}
finally
{
//关闭Socket并释放资源
mNTPSocket.Close();
}
return false;
}
else
{
Debug.LogError($"未初始化IP地址,网络对时失败!");
return false;
}
}

/// <summary>
/// 是否有NTP网址
/// </summary>
/// <returns></returns>
private bool HasHost()
{
return mNTPHostList != null && mNTPHostList.Count > 0;
}

/// <summary>
/// 获取指定偏移的时间戳
/// </summary>
/// <param name="byteDatas"></param>
/// <param name="byteOffset"></param>
/// <returns></returns>
private ulong GetMilliSeconds(byte[] byteDatas, int byteOffset)
{
// Note:
// 64bit时间戳,高32bit表示整数部分,低32bit表示小数部分
// 默认网络获取的时间戳是大端
ulong intPart = BitConverter.ToUInt32(mNtpReceiveData, ServerReplyTimePos);
ulong fractPart = BitConverter.ToUInt32(mNtpReceiveData, ServerReplyTimePos + 4);
// 转换成小端
if(ByteUtilities.IsLittleEndian)
{
// 注意只转换64里的低32位
intPart = ByteUtilities.SwapEndianU32(intPart);
fractPart = ByteUtilities.SwapEndianU32(fractPart);
}
return (intPart * 1000) + ((fractPart * 1000) / 0x100000000UL);
}
}
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
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// 二进制辅助静态工具类
/// </summary>
public static class ByteUtilities
{
/// <summary>
/// 是否是小端
/// </summary>
public static bool IsLittleEndian = CheckLittleEndian();

/// <summary>
/// 当前设备是否是小端
/// </summary>
/// <returns></returns>
public static unsafe bool CheckLittleEndian()
{
int i = 1;
byte* b = (byte*)&i;
return b[0] == 1;
}

/// <summary>
/// UInt16大小端转换
/// </summary>
/// <param name="value"></param>
/// <returns></returns>
public static ushort SwapEndianU16(ushort value)
{
return (ushort)((value & 0xFFU) << 8 | (value & 0xFF00U) >> 8);
}

/// <summary>
/// UInt32大小端转换
/// </summary>
/// <param name="value"></param>
/// <returns></returns>
public static uint SwapEndianU32(uint value)
{
return (value & 0x000000FFU) << 24 | (value & 0x0000FF00U) << 8 |
(value & 0x00FF0000U) >> 8 | (value & 0xFF000000U) >> 24;
}

/// <summary>
/// UInt64大小端32位转换
/// </summary>
/// <param name="value"></param>
/// <returns></returns>
public static ulong SwapEndianU32(ulong value)
{
return (value & 0x000000FFU) << 24 | (value & 0x0000FF00U) << 8 |
(value & 0x00FF0000U) >> 8 | (value & 0xFF000000U) >> 24;
}

/// <summary>
/// UInt64大小端64位转换
/// </summary>
/// <param name="value"></param>
/// <returns></returns>
public static ulong SwapEndianU64(ulong value)
{
return (value & 0x00000000000000FFUL) << 56 | (value & 0x000000000000FF00UL) << 40 |
(value & 0x0000000000FF0000UL) << 24 | (value & 0x00000000FF000000UL) << 8 |
(value & 0x000000FF00000000UL) >> 8 | (value & 0x0000FF0000000000UL) >> 24 |
(value & 0x00FF000000000000UL) >> 40 | (value & 0xFF00000000000000UL) >> 56;
}
}

从上面的代码可以看出,我们为了确保对时的快速,选择了UDP而非TCP。

其次通过解析NTP网址返回的NTP报文信息(e.g. 服务器接受消息时间和服务器返回消息时间),我们可以计算出客户端和服务器的时间差,从而计算出正确的网络UTC时间。

网路延时 = (客户端接收时间 - 客户端发送时间) - (服务器返回消息时间 - 服务器接受消息时间)

时间差 = 服务器接受消息时间 - 客户端发送时间 - 网络延时 / 2 = ((服务器接受消息时间 - 客户端发送时间) + (服务器返回消息时间 - 客户端接收时间)) / 2

当前同步服务器时间 = 客户端接收时间 + 时间差

对时成功后,我们把对时的UTC时间设置到我们本地时间工具类里供访问使用:

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
/*
* Description: TimeHelper.cs
* Author: TonyTang
* Create Date: 2022/07/16
*/

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

/// <summary>
/// 时间静态类
/// </summary>
public static class TimeHelper
{
/// <summary>
/// 同步网络UTC时间
/// </summary>
private static DateTime? mSyncUTCTime;

/// <summary>
/// 同步网络本地时间
/// </summary>
private static DateTime? mSyncLocalTime;

/// <summary>
/// 设置当前UTC时间
/// </summary>
/// <param name="nowDateTime"></param>
public static void SetNowUTCTime(DateTime? nowDateTime)
{
mSyncUTCTime = nowDateTime;
mSyncLocalTime = nowDateTime != null ? ((DateTime)nowDateTime).ToLocalTime() : nowDateTime;
if (mSyncUTCTime != null)
{
Debug.Log($"同步当前UTC时间:{mSyncUTCTime.ToString()}");
Debug.Log($"同步当前时区时间:{mSyncLocalTime.ToString()}");
}
else
{
Debug.Log($"清空当前同步时间!");
}
}

/// <summary>
/// 获取当前同步UTC时间(没有成功同步返回本地UTC时间)
/// </summary>
/// <returns></returns>
public static DateTime GetNowUTCTime()
{
return mSyncUTCTime != null ? (DateTime)mSyncUTCTime : GetLocalNowUTCTime();
}

/// <summary>
/// 获取当前同步本地时区时间(没有成功同步返回本地时区时间)
/// </summary>
/// <returns></returns>
public static DateTime GetNowLocalTime()
{
return mSyncUTCTime != null ? ((DateTime)mSyncUTCTime).ToLocalTime() : GetLocalNowTime();
}

/// <summary>
/// 获取本地当前UTC时间
/// </summary>
/// <returns></returns>
public static DateTime GetLocalNowUTCTime()
{
return DateTime.UtcNow;
}

/// <summary>
/// 获取本地当前时间
/// </summary>
/// <returns></returns>
public static DateTime GetLocalNowTime()
{
return DateTime.Now;
}
}

来看看实战效果:

我把本地时间同步关闭,并修改时间从2022/07/22 00:06到2022/07/24/03:06时间

同步时间前:

同步时间前

同步时间后:

同步时间后

从上面可以看到通过NTP的对时后,我们成功获取到了网络的UTC时间。

但上面的代码还有一个问题,对时后的UTC时间是固定的,我们无法向DateTime.Now或DateTime.UtcNow的方式实时访问同步的UTC时间。

这个时候我们需要通过网络同步时间+本地运行时间=本地最新网络同步时间的方式计算出最新的本地网络同步时间,毕竟我们整个游戏开发过程中并不想一直去通过短连接同步网络时间。

关于本地运行时间Unity给我们提供了一个无关Time.scale变化的一个运行时间,那就是Time.realtimeSinceStartup(标识游戏启动开始算起经历的时间s,不受Time.scale影响)

当前同步网络时间公式:

当前同步网络时间 = 网络对时 + (本地运行时长 - 对时时本地运行时长)

当前同步网络UTC时间

最终代码如下:

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
/*
* Description: TimeHelper.cs
* Author: TonyTang
* Create Date: 2022/07/16
*/

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

/// <summary>
/// 时间静态类
/// </summary>
public static class TimeHelper
{
/// <summary>
/// 同步网络UTC时间
/// </summary>
private static DateTime? mSyncUTCTime;

/// <summary>
/// 同步网络本地时间
/// </summary>
private static DateTime? mSyncLocalTime;

/// <summary>
/// 同步时间时的本地运行时间
/// </summary>
private static float mSyncRealtime;

/// <summary>
/// 设置当前UTC时间
/// </summary>
/// <param name="nowDateTime"></param>
public static void SetNowUTCTime(DateTime? nowDateTime)
{
mSyncUTCTime = nowDateTime;
mSyncLocalTime = nowDateTime != null ? ((DateTime)nowDateTime).ToLocalTime() : nowDateTime;
if (mSyncUTCTime != null)
{
mSyncRealtime = Time.realtimeSinceStartup;
Debug.Log($"同步时间时本地运行时间:{mSyncRealtime}");
Debug.Log($"同步当前UTC时间:{mSyncUTCTime.ToString()}");
Debug.Log($"同步当前时区时间:{mSyncLocalTime.ToString()}");
}
else
{
Debug.Log($"清空当前同步时间!");
}
}

/// <summary>
/// 获取当前同步UTC时间(没有成功同步返回本地UTC时间)
/// </summary>
/// <returns></returns>
public static DateTime GetNowUTCTime()
{
if(mSyncUTCTime != null)
{
var syncUTCTime = (DateTime)mSyncUTCTime;
return syncUTCTime.AddSeconds(Time.realtimeSinceStartup - mSyncRealtime);
}
else
{
return GetLocalNowUTCTime();
}
}

/// <summary>
/// 获取当前同步本地时区时间(没有成功同步返回本地时区时间)
/// </summary>
/// <returns></returns>
public static DateTime GetNowLocalTime()
{
if (mSyncLocalTime != null)
{
var syncLocalTime = (DateTime)mSyncLocalTime;
return syncLocalTime.AddSeconds(Time.realtimeSinceStartup - mSyncRealtime);
}
else
{
return GetLocalNowTime();
}
}

/// <summary>
/// 获取本地当前UTC时间
/// </summary>
/// <returns></returns>
public static DateTime GetLocalNowUTCTime()
{
return DateTime.UtcNow;
}

/// <summary>
/// 获取本地当前时间
/// </summary>
/// <returns></returns>
public static DateTime GetLocalNowTime()
{
return DateTime.Now;
}
}

可以看到我们通过记录同步网络时间时的本地真实运行时间,在下一次获取当前网络时间时通过当前真实运行时间 - 同步网络时本地真实运行时间得出同步时间后真实运行的时间,从而计算出当前网络最新时间。

成功同步时间以后,如果我们制作的游戏要支持多个国家的话,我们需要统一游戏里的时间计算,这个时候我们会用到UTC时间和时间戳的概念。结合前面时间戳的介绍,我知道Unix时间戳是基于1970年1月1日0点0分0秒,那么通过网络UTC对时后,我们可以成功计算出网络对时的时间戳:

当前时间戳 = 当前同步网络时间 - Unix时间戳基准时间(1970/1/1)

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
/// <summary>
/// Unix时间戳基准时间
/// </summary>
private static DateTime UnixBaseTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);

var offsetTimeSpan = (DateTime)mSyncUTCTime - UnixBaseTime;
mSyncUTCTimeStamp = (long)offsetTimeSpan.TotalSeconds;
Debug.Log($"同步时间UTC时间戳:{mSyncUTCTimeStamp}");

/// <summary>
/// 获取当前同步UTC时间戳(没有成功同步返回本地UTC时间戳)
/// </summary>
/// <returns></returns>
public static long GetNowUTCTimeStamp()
{
var nowUTCTime = GetNowUTCTime();
var offsetSeconds = nowUTCTime - UnixBaseTime;
return (long)offsetSeconds.TotalSeconds;
}

/// <summary>
/// 获取本地当前UTC时间戳
/// </summary>
/// <returns></returns>
public static long GetLocalNowUTCTimeStamp()
{
return DateTimeOffset.UtcNow.ToUnixTimeSeconds();
}

同步UTC时间前时间戳:

同步UTC时间前时间戳

同步UTC时间后时间戳:

同步UTC时间后时间戳

可以看到我们通过和Unix时间戳基准时间(1970年1月1月0时0分0秒)成功计算出了当前对时的UTC时间戳

重点知识

  1. 同步网络对网络要求不高,可以采用UDP
  2. NTP报文里有服务器相关获得请求时间和服务求返回请求时间,通过这些数据的解析我们能计算出真实的网络时间
  3. NTP里的时间戳是基于1900年1月1日,而非像Unix时间戳基于1970年1月1日
  4. 本地最新网络时间需要通过记录对时时的运行时间来计算最新的当前网络时间
  5. 网络时间同步没必要一直同步,可以采用短连接在必要的时候同步一次

引用

网络时间同步是怎么实现的?怎样消除延迟带来的影响?

统一标准时间(UTC)

网络时间协议(Network Time Protocol, 缩写NTP)

通过NTP协议进行时间同步

ntp原理及客户端实现

客户端秒级时间同步方案

Unix时间戳

Github

SyncTime

前言

日常项目中,我们对于Unity资源导入会做一些自动化的设置和检查,确保导入到项目里的资源是正确符合要求的,确保游戏资源最优化。而这些自动化检查和处理离不开AssetPostProcess接口,常规的方式是通过重写AssetPostProcess相关接口进行硬编码的方式实现资源导入自动化检查和设置。但这样的实现方式显得不灵活,当我们需要改变或新增一些资源时,我们需要硬编码去实现需求。

为了寻求高效高度扩展性的Asset管线系统,从而引出了本篇文章实现的可配置化Asset管线系统。

Asset管线设计

  1. 使用ScriptableObject实现自定义配置数据存储,结合导出Json实现无需等待配置Asset导入就可读取数据进行Asset管线系统初始化
  2. 使用EditorWindow实现自定义配置窗口
  3. 使用AssetPostProcess实现Asset后处理系统流程接入
  4. 设计AssetPipeline和AssetPipelineSystem实现Asset管线系统流程初始化(利用InitializeOnLoadMethodAttribute标签实现Asset管线提前初始化)
  5. 设计AssetProcessorSystem和AssetCheckSystem实现Asset管线的2大系统(Asset处理系统和Asset检查系统)
  6. 支持配置多个Asset管线策略以及不同平台选用不同策略来实现平台各异化的管线系统配置
  7. 支持Asset管线预处理,后处理,移动处理和删除处理器配置
  8. 支持全局和局部两种大的类型处理器和检查器配置
  9. 支持Asset管线预检查和后检查检查器配置
  10. 支持局部Asset处理器和检查器黑名单目录配置
  11. 处理器和检查器支持指定Asset类型级别设置
  12. 处理器和检查器支持指定Asset管线处理类型(AssetPostProcess相关流程接口)级别设置
  13. 支持所有处理器和检查器UI预览查看

Note:

  1. Asset管线先执行全局后局部,局部Asset处理由外到内执行(覆盖关系)
  2. Asset管线移动Asset当做重新导入Asset处理,确保Asset移动后得到正确的Asset管线处理
  3. Asset管线不满足条件的不会触发(比如: 1. 不在目标资源目录下 2. 在黑名单列表里)
  4. 配置符合的触发顺序如下: 触发Asset导入全局预检查 -> 触发Asset导入局部预检查 -> 触发Asset导入全局预处理 -> 触发Asset导入局部预处理 -> 触发Asset导入全局后处理 -> 触发Asset导入局部后处理 -> 触发Asset导入全局后检查 -> 触发Asset导入局部后检查
  5. 处理器和检查器支持指定需要处理的Asset类型组合,但Asset管线处理类型只能选择一个
  6. 通用后处理类型包含后处理,移动处理和删除处理

类说明

Asset管线核心框架部分:

  • AssetType – Asset类型定义
  • AssetProcessType – Asset管线处理类型定义
  • AssetPipeline – Asset管线入口(负责接入Asset后处理流程以及Asset管线系统初始化)
  • AssetPipelineSystem – Asset管线系统(负责初始化Asset管线里各系统以及各系统调用)
  • AssetProcessorSystem – Asset处理系统(负责Asset自动化后处理相关配置)
  • AssetCheckSystem – Asset检查系统(负责Asset自动化检查相关配置)
  • AssetPipelineSettingData – Asset管线系统配置相关数据(非ScriptableObject)(比如Asset管线开关,Log开关等配置)
  • ProcessorSettingData – Asset处理器设置数据(AssetPipelineWindow 窗口配置)
  • ProcessorConfigData – Asset处理器配置数据(AssetPipelineWindow 窗口配置)
  • AssetCheckSettingData – Asset检查设置数据(AssetPipelineWindow 窗口配置)
  • AssetCheckConfigData – Asset检查配置数据(AssetPipelineWindow 窗口配置)
  • BaseProcessor – Asset处理器抽象类(负责抽象Asset处理器流程和数据,继承至ScriptableObject实现自定义数据配置处理)
  • BaseProcessorJson – Asset处理器Json抽象类(负责抽象Asset处理器流程和数据定义(和BaseProcessor保持一致用于Json反序列化构造),实现自定义数据配置处理)
  • BasePreProcessor – Asset预处理器抽象类(负责抽象Asset预处理器流程和数据,继承至BaseProcessor )
  • BasePreProcessorJson – Asset预处理器Json抽象类(负责抽象Asset预处理器流程和数据定义(和BasePreProcessor保持一致用于Json反序列化构造),继承至BaseProcessorJson)
  • BasePostProcessor – Asset预处理器抽象类(负责抽象Asset后处理器流程和数据,继承至BaseProcessor )
  • BasePostProcessJson – Asset预处理器Json抽象类(负责抽象Asset后处理器流程和数据定义(和BasePostProcessor保持一致用于Json反序列化构造),继承至BaseProcessorJson)
  • BaseCheck – Asset检查抽象类(负责抽象Asset检查器流程和数据,继承至ScriptableObject实现自定义数据配置处理)
  • BaseCheckJson – Asset检查器Json抽象类(负责抽象Asset检查器流程和数据定义(和BaseCheck保持一致用于Json反序列化构建),实现自定义数据配置处理)
  • BasePreCheck – Asset预检查抽象类(负责抽象Asset预检查器流程和数据,继承至BaseCheck )
  • BasePreCheckJson – Asset预检查器Json抽象类(负责抽象Asset预检查器流程和数据定义(和BasePreCheck保持一致用于Json反序列化构建),继承至BaseCheckJson)
  • BasePostCheck – Asset后检查抽象类(负责抽象Asset后检查器流程和数据,继承至BaseCheck )
  • BasePostCheckJson – Asset后检查器Json抽象类(负责抽象Asset后检查器流程和数据定义(和BasePostCheck保持一致用于Json反序列化构建),继承至BaseCheckJson)
  • AssetInfo – 用于记录处理器和检查器相关的路径信息和类型信息(用于后续Json反序列依据)
  • AssetProcessorInfoData – 所有处理器AssetInfo信息集合(用于后续Json反序列化构造处理器依据)
  • AssetCheckInfoData – 所有检查器AssetInfo信息集合(用于后续Json反序列化构造检查器依据)
  • 更多的数据定义这里就不一一列举了

窗口部分:

  • AssetPipelineWindow – Asset管线可配置化配置窗口
  • AssetPipelinePanel – Asset管线面板
  • AssetProcessorPanel – Asset处理器面板
  • AssetCheckPanel – Asset检查器面板
  • LocaDetailWindow – Asset管线局部数据详情配置窗口(用于配置局部数据黑名单路径配置)

其他部分:

  • AssetPipelineUtilities – Asset管线工具类(负责提供Asset管线相关工具方法)
  • AssetPipelineStyles – Asset管线用到的GUI显示风格定义
  • AssetPipelineGUIContent – Asset管线用到的显示图标定义
  • AssetPipelineConst – Asset管线用到的常量定义
  • AssetPipelineLog – Asset管线自定义Log
  • AssetPipelinePrefKey – Asset管线本地存储Key定义

实战实现

在了解详细的细节实现之前,先了解下Asset管线是如何定义Asset类型和Asset管线处理类型的,这个对于后续实战配置理解会有帮助。

  • Asset类型定义

    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
    /// <summary>
    /// AssetType.cs
    /// Asset类型
    /// </summary>
    [Flags]
    public enum AssetType : Int32
    {
    None = 0, // 无效类型
    Texture = 1 << 0, // 图片
    Material = 1 << 1, // 材质
    SpriteAtlas = 1 << 2, // 图集
    FBX = 1 << 3, // 模型文件
    AudioClip = 1 << 4, // 音效
    Font = 1 << 5, // 字体
    Shader = 1 << 6, // Shader
    Prefab = 1 << 7, // 预制件
    ScriptableObject = 1 << 8, // ScriptableObject
    TextAsset = 1 << 9, // 文本Asset
    Scene = 1 << 10, // 场景
    AnimationClip = 1 << 11, // 动画文件
    Mesh = 1 << 12, // Mesh文件
    Script = 1 << 13, // 脚本(e.g. cs, dll等)
    Video = 1 << 14, // 视频
    Folder = 1 << 15, // 文件夹
    Other = 1 << 16, // 其他
    All = Int32.MaxValue, // 所有类型
    }

    上面的Asset类型只所以定义成Flags是为了处理器和检查器可以定义需要支持的Asset类型组合

  • Asset管线处理类型定义

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    /// <summary>
    /// AssetProcessorType.cs
    /// Asset管线处理类型
    /// </summary>
    public enum AssetProcessType
    {
    CommonPreprocess = 1, // 通用预处理
    PreprocessAnimation, // 动画预处理
    PreprocessAudio, // 音效预处理
    PreprocessModel, // 模型预处理
    PreprocessTexture, // 纹理预处理
    CommonPostprocess, // 通用后处理
    PostprocessAnimation, // 动画后处理
    PostprocessModel, // 模型后处理
    PostprocessMaterial, // 材质后处理
    PostprocessPrefab, // 预制件后处理
    PostprocessTexture, // 纹理后处理
    PostprocessAudio, // 音效后处理
    }

    上面的类型分别对应AssetPostprocessor里的Asset处理接口,这里就不详细介绍了,具体看代码。

配置窗口

Asset管线策略配置面板

此窗口主要负责配置Asset管线策略以及不同平台Asset管线策略的使用。同时也负责选择当前配置的策略和设置全局的资源处理目录。

Asset管线全局预处理器配置面板

Asset管线全局后处理器配置面板

全局后处理器,移动处理器和删除处理器配置面板类似就不一一截图了,他们分别对应通用后处理里的imported,moved和deleted流程。

Asset管线局部预处理器配置面板

从上面可以看到我们对于局部处理器的配置方式,是通过制定目录,选择处理器列表的方式实现的,同时每个处理器还支持制定黑名单目录(用于部分处理器子目录不生效的配置)

通过点击数量(*)我们可以打开指定局部处理器配置指定处理器的黑名单详情设置窗口

Asset管线局部数据黑名单目录配置窗口

通过上面的黑名单目录配置结合前面的局部预处理配置我们可以看出,我们希望ETC2设置对于Assets/Res/textures目录生效但不希望对Assets/Res/textures/TestBlackList目录生效,对于TestBlackList目录我们单独设置了ASTC处理器设置。

Asset管线局部后处理器配置面板

关于移动处理器和删除处理器和预处理器和后处理器配置类似的,只是对应不同处理流程,这里就不一一截图举例了。

Asset管线处理器预览面板

检查器窗口配置和处理器窗口配置是类似的,只是检查器只支持了预检查器和后检查器,这里我就只简单截一张效果图看看,就不详细说明了

Asset管线全局预检查器配置面板

Asset管线检查器预览面板

了解了配置窗口,接下来看看我们是如何在Asset管线框架基础上,实现定义这些自定义的处理器和检查器的。

自定义处理器和检查器

自定义的检查器需要通过继承BasePreProcessor或BasePostProcessor或BasePreCheck或BasePostCheck来实现的

CheckFileName.cs预检查器ScriptableObject数据定义:

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
/// <summary>
/// CheckFileName.cs
/// 检查文件名
/// </summary>
[CreateAssetMenu(fileName = "CheckFileName", menuName = "ScriptableObjects/AssetPipeline/AssetCheck/PreCheck/CheckFileName", order = 2001)]
public class CheckFileName : BasePreCheck
{
/// <summary>
/// 检查器名
/// </summary>
public override string Name
{
get
{
return "检查文件名";
}
}

/// <summary>
/// 目标Asset类型
/// </summary>
public override AssetType TargetAssetType
{
get
{
return AssetType.All;
}
}

/// <summary>
/// 目标Asset管线处理类型
/// </summary>
public override AssetProcessType TargetAssetProcessType
{
get
{
return AssetProcessType.CommonPreprocess;
}
}
}

CheckFileNameJson.cs预检查器Json处理流程和数据定义:

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
/// <summary>
/// CheckFileSizeJson.cs
/// 检查文件大小检查器Json
/// </summary>
[Serializable]
public class CheckFileSizeJson : BasePreCheckJson
{
/// <summary>
/// 检查器名
/// </summary>
public override string Name
{
get
{
return "检查文件大小";
}
}

/// <summary>
/// 目标Asset类型
/// </summary>
public override AssetType TargetAssetType
{
get
{
return AssetPipelineSystem.GetAllCommonAssetType();
}
}

/// <summary>
/// 目标Asset管线处理类型
/// </summary>
public override AssetProcessType TargetAssetProcessType
{
get
{
return AssetProcessType.CommonPreprocess;
}
}

/// <summary>
/// 处理器触发排序Order
/// </summary>
public override int Order
{
get
{
return 2;
}
}

/// <summary>
/// 文件大小限制
/// </summary>
public int FileSizeLimit = 1024 * 1024 * 8;

/// <summary>
/// 执行检查器处理
/// </summary>
/// <param name="assetPostProcessor"></param>
/// <param name="paramList">不定长参数</param>
protected override bool DoCheck(AssetPostprocessor assetPostProcessor, params object[] paramList)
{
return DoCheckFileSize(assetPostProcessor.assetPath);
}

/// <summary>
/// 执行指定路径的检查器处理
/// </summary>
/// <param name="assetPath"></param>
protected override bool DoCheckByPath(string assetPath, params object[] paramList)
{
return DoCheckFileSize(assetPath);
}

/// <summary>
/// 执行文件大小检查
/// </summary>
/// <param name="assetPath"></param>
/// <returns></returns>
private bool DoCheckFileSize(string assetPath)
{
var assetFullPath = PathUtilities.GetAssetFullPath(assetPath);
using (FileStream fs = File.Open(assetFullPath, FileMode.Open))
{
var overSize = fs.Length > FileSizeLimit;
if (!overSize)
{
AssetPipelineLog.Log($"AssetPath:{assetPath}文件大小检查,实际大小:{fs.Length / 1024f / 1024f}M,限制大小:{FileSizeLimit / 1024f / 1024f}M".WithColor(Color.yellow));
}
else
{
AssetPipelineLog.LogError($"AssetPath:{assetPath}文件大小检查,实际大小:{fs.Length / 1024f / 1024f}M,限制大小:{FileSizeLimit / 1024f / 1024f}M".WithColor(Color.yellow));
}
return !overSize;
}
}
}

从上面可以看到我们通过CheckFileName继承BasePreCheck表明我们是实现一个预检查器。通过声明AssetType为AssetType.All表明我们要支持的所有的Asset类型处理。通过声明TargetAssetProcessType为AssetProcessType.CommonPreprocess表示我们要支持通用预处理流程。

同时为了支持在InitializeOnLoadMethodAttribute方法里读取非Asset相关类型信息和数据进行初始化(后续会讲到为什么),我们定义了对应的CheckFileNameJson类来实现处理流程和数据定义。因为CheckFileNameJson和CheckFileName的序列化数据定义一致,所以通过CheckFileName的ScriptableObject导出的Json数据是可以成功反序列化读取的。最终我们使用*Json类实现了对配置数据的反序列化从而填充到我们的Asset管线系统里作为后处理数据依据。

ASTC格式设置预处理器ScriptableObeject数据定义:

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
/// <summary>
/// ASTCSet.cs
/// ASTC设置
/// </summary>
[CreateAssetMenu(fileName = "ASTCSet", menuName = "ScriptableObjects/AssetPipeline/AssetProcessor/PreProcessor/ASTCSet", order = 1001)]
public class ASTCSet : BasePreProcessor
{
/// <summary>
/// 检查器名
/// </summary>
public override string Name
{
get
{
return "ASTC设置";
}
}

/// <summary>
/// 目标Asset类型
/// </summary>
public override AssetType TargetAssetType
{
get
{
return AssetType.Texture;
}
}

/// <summary>
/// 目标Asset管线处理类型
/// </summary>
public override AssetProcessType TargetAssetProcessType
{
get
{
return AssetProcessType.PreprocessTexture;
}
}

/// <summary>
/// 目标纹理格式
/// </summary>
[Header("目标纹理格式")]
public TextureImporterFormat TargetTextureFormat = TextureImporterFormat.ASTC_4x4;
}

ASTC格式设置预处理器对应*Json的处理流程和数据定义:

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
/// <summary>
/// ASTCSetJson.cs
/// ASTC设置预处理器Json
/// </summary>
[Serializable]
public class ASTCSetJson : BasePreProcessorJson
{
/// <summary>
/// 检查器名
/// </summary>
public override string Name
{
get
{
return "ASTC设置";
}
}

/// <summary>
/// 目标Asset类型
/// </summary>
public override AssetType TargetAssetType
{
get
{
return AssetType.Texture;
}
}

/// <summary>
/// 目标Asset管线处理类型
/// </summary>
public override AssetProcessType TargetAssetProcessType
{
get
{
return AssetProcessType.PreprocessTexture;
}
}

/// <summary>
/// 处理器触发排序Order
/// </summary>
public override int Order
{
get
{
return 1;
}
}

/// <summary>
/// 目标纹理格式
/// </summary>
public TextureImporterFormat TargetTextureFormat = TextureImporterFormat.ASTC_4x4;

/// <summary>
/// 执行处理器处理
/// </summary>
/// <param name="assetPostProcessor"></param>
/// <param name="paramList">不定长参数列表</param>
protected override void DoProcessor(AssetPostprocessor assetPostProcessor, params object[] paramList)
{
var assetImporter = assetPostProcessor.assetImporter;
DoASTCSet(assetImporter);
}

/// <summary>
/// 执行指定路径的处理器处理
/// </summary>
/// <param name="assetPath"></param>
/// <param name="paramList">不定长参数列表</param>
protected override void DoProcessorByPath(string assetPath, params object[] paramList)
{
var assetImporter = AssetImporter.GetAtPath(assetPath);
DoASTCSet(assetImporter);
}

/// <summary>
/// 执行ASTC设置
/// </summary>
/// <param name="assetImporter"></param>
private void DoASTCSet(AssetImporter assetImporter)
{
var textureImporter = assetImporter as TextureImporter;
var actiivePlatformName = EditorUtilities.GetPlatformNameByTarget(EditorUserBuildSettings.activeBuildTarget);
var platformTextureSettings = textureImporter.GetPlatformTextureSettings(actiivePlatformName);
var automaticFormat = textureImporter.GetAutomaticFormat(actiivePlatformName);
var isAutomaticASTC = ResourceUtilities.IsASTCFormat(automaticFormat);
var textureFormat = isAutomaticASTC ? automaticFormat : TargetTextureFormat;
platformTextureSettings.overridden = true;
platformTextureSettings.format = textureFormat;
textureImporter.SetPlatformTextureSettings(platformTextureSettings);
AssetPipelineLog.Log($"设置AssetPath:{assetImporter.assetPath}纹理压缩格式:{textureFormat}".WithColor(Color.yellow));
}
}

ASTC设置预处理器和检查文件名预处理器类似,这里就不一一说明了。

但从上面都可以看到每一个处理器和检查器我们都定义了CreateAssetMenu和一些自定义序列化属性,这样我们就可以通过右键创建指定处理器和检查器Asset了。

自定义处理器Asset创建

自定义处理器Asset

处理器自定义参数设置

从上面可以看到同一个类型定义的处理器,我们可以通过创建多个然后改变处理器参数实现细节不同处理效果的处理器需求。

Asset管线处理

接下来让我们看看根据实际Asset管线配置,我们的Asset导入是如何触发一系列配置的。

Mix文件夹局部预处理器配置

Mix文件夹Asset导入纹理Log

Mix文件夹导入纹理处理设置

可以看到通过配置好的Asset管线流程,我们导入到Mix文件夹的纹理图片,成功触发了一系列的处理器和检查器配置,并最终实现了可配置化检查和处理Asset后处理设置的需求。

重点知识

  1. 在DoProcessor和DoProcessorByPath流程接口里有params object[] paramList参数,这个参数就是为了兼容不同Asset管线处理流程数据传递问题的。指定处理器流程的处理器或检查器可以通过强转paramList得到Asset管线流程一开始传递的原始数据。
  2. Asset类型是通过后缀名和类型映射硬编码实现的,如有不支持的文件后置和文件类型,请自行添加。
  3. 检查器只支持了预检查器和后检查器,如果未来有移动和删除检查器的需求也是可以支持的
  4. 检查器目前检查不符合只是打印日志警告,未来想做更多的处理(e.g. 不满足检查设置的自动移动到指定目录去)

注意事项

  1. 默认Editor/AssetPipeline/Config/AssetProcessors和Editor/AssetPipeline/Config/AssetChecks目录下才会生成预览,请创建在这两个目录下
  2. 生成AB名处理器会在后处理设置完成后导致Asset处理未保存状态,导致相同Asset设置AB名会重复触发Post Import流程,暂时通过判定AB名是否相同避免重复设置AB名避免重复进入Post Import流程

设计实现难点

问题:

  1. 原设计全部是基于ScriptableObejct作为数据存储媒介,导致各电脑同步时配置数据也处于导入状态导致无法正确加载最新的后处理配置(更坏的情况可能加载失败)。(2022/8/31)
  2. InitializeOnLoadMethodAttribute标记的方法里无法使用跟Asset相关的类型(比如ScriptableObject相关类)信息也不允许加载Asset相关资源,如果加载Asset相关类型信息会在触发后处理接口的时候发现Asset相关的类型信息发生了Type Exception(类型丢失)(2023/10/19)
  3. InitializeOnLoadMethodAttribute标签只能确保部分流程重新初始化AssetPipeline系统,但只有Asset导入(比如协同更新AssetPipeline配置文件时)时并不会触发InitializeOnLoadMethodAttribute,导致Asset未能按最新的AssetPipeline配置执行后处理(2024/12/19)

解决方案:

  1. 所有ScriptableObject配置导出一份Json(基于ScritableObject的引用改存AssetPath),保存时基于ScriptableObject配置生成最新的Json数据。Asset管线系统加载配置数据基于导出的Json,ScriptableObject只作为可视化配置读取的数据来源以及导出Json的数据依据。(2022/8/31)
  2. 通过拆分配置数据(比如ASTCSet相关的ScriptableObject只负责数据定义)和处理流程(比如ASTCSetJson负责定义和ASTCSet一致的数据实现配置数据反序列化构建+处理流程定义)实现在InitializeOnLoadMethodAttribute标记的方法里读取Asset管线配置数据进行Asset管线系统数据初始化,作为我们Asset管线后处理数据依据(2023/10/19)
  3. 通过FileSystemWatcher进行对AssetPipeline配置文件保存目录的json文件变化监听,实现AssetPipeline配置文件协同更新时重新初始化AssetPipeline系统(如果用户更新时直接待在Unity界面提前触发Asset导入会导致部分Asset导入时还未加载最新的AssetPipeline配置文件,这个问题暂时没有好的解决方案,欢迎提供好的解决方案)(InitializeOnLoadMethodAttribute+FileSystemWatcher解决99.99%使用情况)

Github地址

AssetPipeline

前言

游戏开发过程中用到的数据我们通常会采用Excel进行配置,这个归功于Excel强大的数据处理和表达能力。无论是Excel的自带的公式计算还是Excel内嵌支持的VBA自定义编程都使得Excel成为了数据配置的一大利器,用好Excel也是数值策划必备的技能之一。本章节通过深入学习Excel的使用,为未来自己的独立游戏的数据配置数值搭建打好基础。

需求

  1. 单表之间数值计算公式编写
  2. 跨表数据关联公式计算编写
  3. 自定义程序功能VBA编写(e.g. 导表)

Excel介绍

Excel is the world’s most used spreadsheet program

Excel is a powerful tool to use for mathematical functions

Excel is pronounced “Eks - sel” It is a spreadsheet program developed by Microsoft. Excel organizes data in columns and rows and allows you to do mathematical functions. It runs on Windows, macOS, Android and iOS.

从上面教程的Excel简单介绍可以看出Excel的强大和运用广泛,本人用Excel主要用于游戏开发,核心目的是实现数值系统(即数据管理)。接下来我会跟着教程一步一步深入学习Excel,从基础的Excel使用到Excel公式,甚至自定义VBA编写程序来实现我们在游戏开发过程中的种种需求。

Excel基础学习

Excel自带功能

  • 一个简单的数学运算运用

    BasicExcelFunction

  • Excel主要由标题栏和Sheet组成

    ExcelComponents

    标题栏为Excel工具栏,可以实现各种Excel操作。而Sheet为Excel数据部分主要有多行多列组成,我们平时编写的行列数据都在Sheet里,一个Excel可以包含多个Sheet。

  • Excel公式运算

    Excel公式用于数学运算,通常由=符号开始。

    用一个比较现实的问题举例,我们通常用Excel记账时,通过填入物品名称,单价和数量,我们会需要算出总价,这个时候我们可以利用公式实现总价根据前面数据进行自动计算的功能。

    PriceCaculation

    可以看到在总价那一列编写公式:=B2*C2我们成功利用商品单价和数量自动计算出了商品总价。

    接下来我们利用Excel自带提供的函数计算所有价格,数量以及总价的总数。

    SUMFormular

  • Excel引用

    Excel的引用分为两种(Relative Reference和Absolute Reference)

  • Excel自带函数
    Excel的强大不单单是简单的加减乘除运算,更多的是Excel支持类似编程方式的函数库+VBA语言编写,从而使得Excel能实现很多强大的自定义功能。
    接下来让我们学习几个Excel里常见的自带函数:

Excel VBA

常规的功能需求,Excel自带的函数和功能就能满足,但涉及到复杂的逻辑运算规则时,Excel自带的函数和功能就力不从心了,这时就需要VBA这样的编程语言的东西出马了。

那么什么是VBA了?
VBA stands for Visual Basic for Applications, an event-driven programming language from Microsoft.
我个人的理解,VBA就是内嵌到Excel的VB,支持了Excel常规需求的一些API接口,我们可以利用这些API实现自定义功能。

待学习……

重点知识

学习连接

Excel Tutorial

VBA Tutorial

Github

ExcelStudy

Introduction

Unity游戏开发过程中我需要掌握一些常规的数学知识(比如线性代数),游戏开发接触的比较多的就是向量和矩阵,本章节用于记录和学习数学相关的知识,好让自己对于平时用到的数学知识有一个归纳和深入的学习总结,避免每次都是久了不用就忘。

推荐书籍:

《3D数学基础:图形与游戏开发》

此书记讲到了游戏开发中需要用到的大部分数学基础知识,以前看这个书的时候是为了学习而学习,没有深入实战,而今天重拾此书,一边实战(结合Unity)一边记录的方式,深入学习游戏开发的数学基础知识,避免出现久了不用就忘的情况,也方便以后温故而知新。

Note:

  1. 为了减少重复的代码展示,后续相同代码展示会采用***代替

3D数学

什么是3D数学?

3D数学是一门和计算几何相关的学科,计算几何则是研究用数值方法解决几何问题的学科

坐标系

笛卡尔坐标系在数学中是一种正交坐标系,由法国数学家勒内·笛卡尔引入而得名。二维的直角坐标系是由两条相互垂直、相交于原点的数线构成的。在平面内,任何一点的坐标是根据数轴上对应的点的坐标设定的。在平面内,任何一点与坐标的对应关系,类似于数轴上点与坐标的对应关系。

3DCartesian

笛卡尔坐标系就是我们以前学习平面几何时学习的坐标系,通过笛卡尔坐标系,我们可以通过x,y,z定义1D,2D或3D等空间的点。

坐标系从大的类型上来分分为两类:

  1. 左手坐标系
  2. 右手坐标系

如何区分左手还是右手坐标系了?

通过将大拇指指向Z轴正方向,如果其余四指握起是从X轴往Y轴那么就是左手坐标系,反之则是右手坐标系

Note:

  1. 大拇指指向X轴,食指指向Y轴,中指指向Z轴
  2. Unity是左手坐标系
  3. OpenGL是右手坐标系

多坐标系

坐标系不止一种,使用多种坐标系的原因是某些信息只能在特定的上下文环境中获得

比如以前学习OpenGL的时候,就了解到一个三角形要想渲染到屏幕上,需要经历MVP的矩阵变换。

M – Model -> World(模型坐标系到世界坐标系)

变换目的:3D模型是基于自身的坐标系存储的。计算模型各顶点在世界空间的位置,以便将模型摆放到世界空间中。

这里的世界坐标系,就是Unity里我们访问Transform.position时访问到的世界坐标所处的坐标系。

WorldCoordinateSystem

而物体坐标系是相对物体自身而言的坐标系,通过勾选Local,我们可以看到物体自身的坐标系:

LocalCoordinateSystem

通过Model -> World我们就得到了物体在世界坐标系里的位置表达信息。

V – World -> View(世界坐标系到相机坐标系)

变换目的:计算物体对相机的相对位置,以便进行后续变换

P – View -> Projection(摄像机坐标系到投影坐标系)

因为摄像机的投影方式有多种(比如透视投影和正交投影),所以从摄像机坐标系到投影坐标系也存在着不同的计算方式。

详情参考以前的学习记录:

Coordinate Transformations & Perspective Projection

后续我们学习矩阵概念的时候再深入理解,核心就是通过矩阵执行坐标系转换。

Note:

  1. 矩阵变换是由M = S(scale) × R(rotation) × T(translation)组成,且不满足交换律,因为S和R都是针对坐标系原点进行的,一旦先执行T,那么相对于坐标系原点的位置就会有所变化,这之后再做S和R就会出现不一样的表现。

实战

接下来通过使用Unity来更深入的理解坐标系变化:

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
/*
* Description: CoordinateStudy.cs
* Author: TONYTANG
* Create Date: 2022/03/11
*/

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

/// <summary>
/// CoordinateStudy.cs
/// 坐标系学习
/// </summary>
public class CoordinateStudy : MonoBehaviour
{
/// <summary>
/// 提示文本
/// </summary>
[Header("提示文本")]
public Text TipTxt;

/// <summary>
/// Cube1
/// </summary>
[Header("Cube1")]
public GameObject Cube1;

/// <summary>
/// Cube2
/// </summary>
[Header("Cube2")]
public GameObject Cube2;

private void Start()
{
var cube1RelativeCube2Pos = Cube2.transform.InverseTransformPoint(Cube1.transform.position);
var cube2RelativeCube1Pos = Cube1.transform.InverseTransformPoint(Cube2.transform.position);
TipTxt.text = $"红色表示Cube1,绿色表示Cube2\n" +
$"Cube1世界坐标:{Cube1.transform.position.ToString()} 旋转角度:{Cube1.transform.eulerAngles.ToString()}\n" +
$"Cube2世界坐标系:{Cube2.transform.position.ToString()} 旋转角度:{Cube2.transform.eulerAngles.ToString()}\n" +
$"Cube1相对Cube2的坐标为:{cube1RelativeCube2Pos.ToString()}\n" +
$"Cube2相对Cube1的坐标为:{cube2RelativeCube1Pos.ToString()}";
}
}

Cube2LocalCoordinateSystem

WorldAndLocalCoordinateCaculate

可以看到我通过调用**transform.InverseTransformPoint()**接口实现了将物体世界坐标转换到另一个物体的局部坐标的转换。因为Cube1在坐标原点且没有旋转,所以Cube2在Cube1物体坐标系里的位置也就是Cube2的世界坐标系位置。而Cube2因为有旋转,所以Cube1在Cube2物体坐标系的位置就需要根据Cube2的本地坐标系来计算了。

考虑到世界坐标系到视口坐标系转换后平时用不上,所以这里就不可视化结果了,通过Camera.WorldToViewportPoint()接口可以把世界坐标转换到摄像机视口坐标。同理Camera.WorldToScreenPoint()可以把世界坐标转换到屏幕坐标,这个接口相对而言更常用些(比如3D映射2D UI显示的时候)。

Note:

  1. 如果想查看物体本地坐标系和世界坐标系的转换矩阵,Unity里可以通过Transform.localToWorldMatrix()和Transform.worldToLocalMatrix()接口拿到变换的矩阵

向量

向量有两种不同但相关相关的意义,一种是纯抽象的数学意义,另一种是几何意义。

数学定义

向量就是一个数字列表,对程序员而言则是另一种类似的概念–数组

行向量(1行3列):

[1, 2, 3]

列向量(一列三行):

[ 1 ]

[ 2 ]

[ 3 ]

向量中的数表达了每个维度上的有向位移。比如上面的向量在坐标系里表达的是相对[0,0,0]原点的X,Y,Z轴分别位移1,2,3

Note:

  1. 注意区分向量和标量,标量是平时用的数字的技术称谓

几何定义

向量是有大小和方向的线段。

向量的大小就是向量的长度(模)。向量有非负的长度。

向量的方向描述了空间中向量的指向。

向量模 = Sqrt(VX * VX + VY * VY + VZ * VZ)

向量里有一个比较重要的定义:

单位向量,单位向量可以用来表示方式,但单位向量的大小(模)为1

单位向量 = 向量 / 向量的模

这里简单说一下向量加法的概念:

A + B = C

几何解释是向量A通过向量B的平移得到向量C(A向量头连接B向量尾的向量)

接下来让我通过可视化绘制世界坐标系和可视化绘制两个Cube的连线来看看向量是如何表达出两个物体之间的方向和大小的。

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
/*
* Description: HandlesUtilities.cs
* Author: TONYTANG
* Create Date: 2022/03/17
*/

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

/// <summary>
/// HandlesUtilities.cs
/// Handles辅助工具
/// </summary>
public static class HandlesUtilities
{
/// <summary>
/// 绘制有颜色的文本
/// </summary>
/// <param name="pos">坐标</param>
/// <param name="text">文本</param>
/// <param name="color">颜色</param>
public static void DrawColorLable(Vector3 pos, string text, Color color)
{
Color preColor = GUI.color;
GUI.color = color;
Handles.Label(pos, text);
GUI.color = preColor;
}
}
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
/*
* Description: VectorStudy.cs
* Author: TONYTANG
* Create Date: 2022/03/17
*/

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

/// <summary>
/// VectorStudy.cs
/// 向量学习
/// </summary>
public class VectorStudy : MonoBehaviour
{
******

private void Start()
{
var cube1ToCube2Vector = Cube2.transform.position - Cube1.transform.position;
var cube2ToCube3Vector = Cube3.transform.position - Cube2.transform.position;
var vectorAdd = cube1ToCube2Vector + cube2ToCube3Vector;
TipTxt.text = $"红色表示Cube1,绿色表示Cube2,蓝色表示Cube3\n" +
$"Cube1世界坐标:{Cube1.transform.position.ToString()} 朝向:{Cube1.transform.forward.ToString()}\n" +
$"Cube2世界坐标系:{Cube2.transform.position.ToString()} 朝向:{Cube2.transform.forward.ToString()}\n" +
$"Cube3世界坐标系:{Cube3.transform.position.ToString()} 朝向:{Cube3.transform.forward.ToString()}\n" +
$"Cube1到Cube2的向量:{cube1ToCube2Vector.ToString()}\n" +
$"Cube1到Cube2的向量长度:{cube1ToCube2Vector.magnitude}\n"+
$"Cube2到Cube3的向量:{cube1ToCube2Vector.ToString()}\n" +
$"1->2 + 2->3 = {vectorAdd.ToString()}";
}
}
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
/*
* Description: VectorStudyEditor.cs
* Author: TONYTANG
* Create Date: 2022/03/17
*/

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

/// <summary>
/// VectorStudyEditor.cs
/// VectorStudy的Editor绘制
/// </summary>
[CustomEditor(typeof(VectorStudy))]
public class VectorStudyEditor : Editor
{
/// <summary>
/// Cube1属性
/// </summary>
SerializedProperty mCube1Property;

/// <summary>
/// Cube2属性
/// </summary>
SerializedProperty mCube2Property;

/// <summary>
/// Cube3属性
/// </summary>
SerializedProperty mCube3Property;

/// <summary>
/// 坐标系长度属性
/// </summary>
SerializedProperty mCoordinateSystemLengthProperty;

/// <summary>
/// Z轴颜色属性
/// </summary>
SerializedProperty mForwardAxisColorProperty;

/// <summary>
/// X轴颜色属性
/// </summary>
SerializedProperty mRightAxisColorProperty;

/// <summary>
/// Y轴颜色
/// </summary>
SerializedProperty mUpAxisColorProperty;

/// <summary>
/// 向量颜色
/// </summary>
SerializedProperty mVectorColorProperty;

/// <summary>
/// Cube1
/// </summary>
private GameObject mCube1;

/// <summary>
/// Cube2
/// </summary>
private GameObject mCube2;

/// <summary>
/// Cube3
/// </summary>
private GameObject mCube3;

protected void OnEnable()
{
mCube1Property = serializedObject.FindProperty("Cube1");
mCube2Property = serializedObject.FindProperty("Cube2");
mCube3Property = serializedObject.FindProperty("Cube3");
mCoordinateSystemLengthProperty = serializedObject.FindProperty("CoordinateSystemLength");
mForwardAxisColorProperty = serializedObject.FindProperty("ForwardAxisColor");
mRightAxisColorProperty = serializedObject.FindProperty("RightAxisColor");
mUpAxisColorProperty = serializedObject.FindProperty("UpAxisColor");
mVectorColorProperty = serializedObject.FindProperty("VectorColor");
}

protected virtual void OnSceneGUI()
{
InitData();
DrawCubeInfo();
DrawWorldCoordinateSystem();
DrawVectorInfo();
}

/// <summary>
/// 初始化数据
/// </summary>
private void InitData()
{
mCube1 = mCube1Property?.objectReferenceValue != null ? mCube1Property.objectReferenceValue as GameObject : null;
mCube2 = mCube2Property?.objectReferenceValue != null ? mCube2Property.objectReferenceValue as GameObject : null;
mCube3 = mCube3Property?.objectReferenceValue != null ? mCube3Property.objectReferenceValue as GameObject : null;
}

/// <summary>
/// 绘制Cube信息
/// </summary>
private void DrawCubeInfo()
{
HandlesUtilities.DrawColorLable(mCube1.transform.position, "Cube1", Color.red);
HandlesUtilities.DrawColorLable(mCube2.transform.position, "Cube2", Color.red);
HandlesUtilities.DrawColorLable(mCube3.transform.position, "Cube3", Color.red);
}

/// <summary>
/// 绘制世界坐标系
/// </summary>
private void DrawWorldCoordinateSystem()
{
HandlesUtilities.DrawColorLable(Vector3.zero, "世界坐标系", Color.red);
if (Event.current.type == EventType.Repaint)
{
Handles.color = mForwardAxisColorProperty.colorValue;
Handles.ArrowHandleCap(1, Vector3.zero,
Quaternion.LookRotation(Vector3.forward),
mCoordinateSystemLengthProperty.floatValue, EventType.Repaint);

Handles.color = mRightAxisColorProperty.colorValue;
Handles.ArrowHandleCap(1, Vector3.zero,
Quaternion.LookRotation(Vector3.right),
mCoordinateSystemLengthProperty.floatValue, EventType.Repaint);

Handles.color = mUpAxisColorProperty.colorValue;
Handles.ArrowHandleCap(1, Vector3.zero,
Quaternion.LookRotation(Vector3.up),
mCoordinateSystemLengthProperty.floatValue, EventType.Repaint);
}
}

/// <summary>
/// 绘制相关向量
/// </summary>
private void DrawVectorInfo()
{
if (Event.current.type == EventType.Repaint)
{
if (mCube1 != null && mCube2 != null && mCube3 != null)
{
var cube1ToCube2Vector = mCube2.transform.position - mCube1.transform.position;
var cube2ToCube3Vector = mCube3.transform.position - mCube2.transform.position;
var vectorAdd = cube1ToCube2Vector + cube2ToCube3Vector;
Handles.color = mVectorColorProperty.colorValue;
Handles.ArrowHandleCap(1, mCube1.transform.position,
Quaternion.LookRotation(cube1ToCube2Vector),
cube1ToCube2Vector.magnitude, EventType.Repaint);
Handles.ArrowHandleCap(1, mCube2.transform.position,
Quaternion.LookRotation(cube2ToCube3Vector),
cube2ToCube3Vector.magnitude, EventType.Repaint);
Handles.ArrowHandleCap(1, mCube1.transform.position,
Quaternion.LookRotation(vectorAdd),
vectorAdd.magnitude, EventType.Repaint);
Handles.Label(mCube1.transform.position + cube1ToCube2Vector / 2, "1->2");
Handles.Label(mCube2.transform.position + cube2ToCube3Vector / 2, "2->3");
Handles.Label(mCube1.transform.position + vectorAdd / 2, "1->2 + 2->3");
}
}
}
}

ObjectConnectVectorAndCoordinateSystem

通过Handles相关接口,我们成功绘制出了以下东西:

  1. 世界坐标系
  2. Cube1到Cube2的向量
  3. Cube2到Cube3的向量
  4. 1->2 + 2->3的向量

可以看到Cube2坐标-Cube1坐标=从Cube1位置指向Cube2位置的向量,也就是说B - A = A->B

同时上面的可视化也显示了,A->B + B->C = A->C的向量加法规则,减法反向理解即可。

接下来我们要学习了解向量里比较重要的两个运算概念:

  1. 点乘
  2. 叉乘

Note:

  1. 方向并不完全和方位相同
  2. 数学中专门研究向量的分支称作线性代数
  3. 向量加法满足交换律,但向量减法不满足交换律。因为得到的向量方向相反。

点乘

点乘(也称为内积),记做a.b

a.b = a1 * b1 + a2 * b2 + a3 * b3 … + an * bn

几何解释:

点乘等于向量大小与向量夹角的cosθ值的积。点乘结果描述了两个向量的”相似”程度,点乘结果越大,两向量越相近。

a.b = |a| * |b| * cosθ

VectorDotGeometric

根据上面的公式和图我们可以看出,点乘在几何里的可以理解成a向量在b向量上的投影乘以b向量的模

通过上面的公式我们反向推到夹角的计算公式如下:

θ = arccor(a.b / |a| * |b|)

如果我们不关心点乘的大小,那么从点乘的值我们可以判定两个向量之间的夹角情况:

  1. a.b > 0 –> 方向基本相同,夹角在0到90度之间
  2. a.b = 0 –> 正交,两向量垂直
  3. a.b < 0 –> 方向基本相反,夹角在90-180度之间

接下来让我们通过Unity实战可视化辅助我们学习向量点乘:

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
/*
* Description: VectorStudyEditor.cs
* Author: TONYTANG
* Create Date: 2022/03/17
*/

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

/// <summary>
/// VectorStudyEditor.cs
/// VectorStudy的Editor绘制
/// </summary>
[CustomEditor(typeof(VectorDotStudy))]
public class VectorDotStudyEditor : Editor
{
******


/// <summary>
/// 绘制相关向量
/// </summary>
private void DrawVectorInfo()
{
if (Event.current.type == EventType.Repaint)
{
if (mCube1 != null && mCube2 != null && mCube3 != null)
{
var cube1ToCube2Vector = mCube2.transform.position - mCube1.transform.position;
var cube1ToCube3Vector = mCube3.transform.position - mCube1.transform.position;
var vectorDot = Vector3.Dot(cube1ToCube2Vector, cube1ToCube3Vector);
var radians = Mathf.Acos(vectorDot / (cube1ToCube2Vector.magnitude * cube1ToCube3Vector.magnitude));
var angle = Mathf.Rad2Deg * radians;
Handles.color = mVectorColorProperty.colorValue;
Handles.ArrowHandleCap(1, mCube1.transform.position,
Quaternion.LookRotation(cube1ToCube2Vector),
cube1ToCube2Vector.magnitude, EventType.Repaint);
Handles.ArrowHandleCap(1, mCube1.transform.position,
Quaternion.LookRotation(cube1ToCube3Vector),
cube1ToCube3Vector.magnitude, EventType.Repaint);
Handles.Label(mCube1.transform.position + cube1ToCube2Vector / 2, "1->2");
Handles.Label(mCube1.transform.position + cube1ToCube3Vector / 2, "1->3");
HandlesUtilities.DrawColorLable(mCube1.transform.position, $"夹角:{angle}", Color.green);
}
}
}
}
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
/*
* Description: VectorDotStudy.cs
* Author: TONYTANG
* Create Date: 2022/03/17
*/

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

/// <summary>
/// VectorDotStudy.cs
/// 向量点乘学习
/// </summary>
public class VectorDotStudy : MonoBehaviour
{
******

private void Start()
{
var cube1ToCube2Vector = Cube2.transform.position - Cube1.transform.position;
var cube1ToCube3Vector = Cube3.transform.position - Cube1.transform.position;
var vectorDot = Vector3.Dot(cube1ToCube2Vector, cube1ToCube3Vector);
var radians = Mathf.Acos(vectorDot / (cube1ToCube2Vector.magnitude * cube1ToCube3Vector.magnitude));
var angle = Mathf.Rad2Deg * radians;
TipTxt.text = $"红色表示Cube1,绿色表示Cube2,蓝色表示Cube3\n" +
$"Cube1世界坐标:{Cube1.transform.position.ToString()} 朝向:{Cube1.transform.forward.ToString()}\n" +
$"Cube2世界坐标系:{Cube2.transform.position.ToString()} 朝向:{Cube2.transform.forward.ToString()}\n" +
$"Cube3世界坐标系:{Cube3.transform.position.ToString()} 朝向:{Cube3.transform.forward.ToString()}\n" +
$"Cube1到Cube2的向量:{cube1ToCube2Vector.ToString()}\n" +
$"Cube1到Cube3的向量:{cube1ToCube3Vector.magnitude}\n" +
$"1->2 点乘 2->3 = {vectorDot}\n" +
$"1->2和2->3的夹角:{angle}";
}
}

VectorDot

通过上面的计算和可视化,可以看到我们通过Mathf.Acos(a.b / (|a| * |b|)) * Mathf.Rad2Deg实现了点乘角度的逆推。

并且因为点乘是a.b = |a| * |b| * cos(θ),所以从点乘的结果我们可以分析出两个向量的角度情况:

a.b > 0表示a和b向量夹角为0度到90度

a.b==0表示a和b向量垂直

a.b<0表示a和b向量夹角为90度到180度

Note:

  1. 点乘满足交换律
  2. 点乘对零向量的解释是,零向量和任意其他向量垂直
  3. Unity Mathf.Acos()返回的是弧度而非角度,需要通过乘以Mathf.Rad2Deg进行角度转换

叉乘

叉乘又称叉积,和点乘不一样,点乘得到一个标量并满足交换律,向量叉乘得到一个向量并且不满足交换律

​ [x1] * [x2] [y1 * z2 - z1 * y1 ]

​ a X b = [y1] * [y2] = [ z1 * x2 - x1 * z2 ]

​ [z1] * [y3] [ x1 * y2 - y1 * x2 ]

几何解释:

叉乘得到的向量垂直于原来的两个向量

VectorCrossVisilization

**|aXb|=|a||b|sinθ

从2维平面几何来说,a向量和b向量的叉乘长度等于以a向量和b向量为边的平行四边形面积。

我们知道了a向量叉乘b向量得到的是一个垂直于a和b平面的向量,那么这个向量是朝上还是朝下是如何决定的了?

通过把a向量和b向量首尾相连,在左手坐标系里如果a和b成顺时针,那么aXb的结果向量向外,反之向里。右手坐标系中则恰好相反。

接下来让我们结合实战,看下我们是如何利用叉乘计算出垂直a和b向量的c向量的:

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
/*
* Description: VectorCrossStudyEditor.cs
* Author: TONYTANG
* Create Date: 2022/03/17
*/

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

/// <summary>
/// VectorCrossStudyEditor.cs
/// VectorCrossStudy的Editor绘制
/// </summary>
[CustomEditor(typeof(VectorCrossStudy))]
public class VectorCrossStudyEditor : Editor
{
******

/// <summary>
/// 绘制相关向量
/// </summary>
private void DrawVectorInfo()
{
if (Event.current.type == EventType.Repaint)
{
if (mCube1 != null && mCube2 != null && mCube3 != null)
{
var cube1Forward = mCube1.transform.forward;
var cube1ToCube2 = mCube2.transform.position - mCube1.transform.position;
var vectorDot = Vector3.Dot(cube1Forward, cube1ToCube2);
var radians = Mathf.Acos(vectorDot / (cube1Forward.magnitude * cube1ToCube2.magnitude));
var angle = Mathf.Rad2Deg * radians;
var vectorCross = Vector3.Cross(cube1Forward, cube1ToCube2);
vectorCross = (angle <= Mathf.Epsilon && angle >= -Mathf.Epsilon) ? cube1Forward : vectorCross;
Handles.color = mVectorColorProperty.colorValue;
Handles.ArrowHandleCap(1, mCube1.transform.position,
Quaternion.LookRotation(cube1Forward),
mCoordinateSystemLengthProperty.floatValue, EventType.Repaint);
Handles.ArrowHandleCap(1, mCube1.transform.position,
Quaternion.LookRotation(cube1ToCube2),
mCoordinateSystemLengthProperty.floatValue, EventType.Repaint);
Handles.ArrowHandleCap(1, mCube1.transform.position,
Quaternion.LookRotation(vectorCross),
mCoordinateSystemLengthProperty.floatValue, EventType.Repaint);
HandlesUtilities.DrawColorLable(mCube1.transform.position, $"夹角:{angle}", Color.green);
}
}
}
}
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
/*
* Description: VectorCrossStudy.cs
* Author: TONYTANG
* Create Date: 2022/03/17
*/

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

/// <summary>
/// VectorCrossStudy.cs
/// 向量叉乘学习
/// </summary>
public class VectorCrossStudy : MonoBehaviour
{
******

/// <summary>
/// 旋转角度
/// </summary>
private float mAngle;

/// <summary>
/// 旋转轴
/// </summary>
private Vector3 mRotateAxis;

private void Start()
{
RotateBtn.onClick.AddListener(OnRotateBtnClick);
var cube1Forward = Cube1.transform.forward;
var cube1ToCube2 = Cube2.transform.position - Cube1.transform.position;
var vectorDot = Vector3.Dot(cube1Forward, cube1ToCube2);
var radians = Mathf.Acos(vectorDot / (cube1Forward.magnitude * cube1ToCube2.magnitude));
mAngle = Mathf.Rad2Deg * radians;
mRotateAxis = Vector3.Cross(cube1Forward, cube1ToCube2);
var rotationDirection = mRotateAxis.y > 0 ? "顺时针" : "逆时针";
TipTxt.text = $"红色表示Cube1,绿色表示Cube2\n" +
$"Cube1世界坐标:{Cube1.transform.position.ToString()} 朝向:{Cube1.transform.forward.ToString()}\n" +
$"Cube2世界坐标系:{Cube2.transform.position.ToString()} 朝向:{Cube2.transform.forward.ToString()}\n" +
$"Cube1朝向:{cube1Forward.ToString()}\n" +
$"Cube1到Cube2向量:{cube1ToCube2.ToString()}\n" +
$"Cube1朝向 点乘 Cube1到Cube2向量 = {vectorDot}\n" +
$"Cube1朝向 叉乘 Cube1到Cube2向量 = {mRotateAxis.ToString()}\n" +
$"Cube1看向Cube2旋转方向:{rotationDirection}\n" +
$"Cube1看向Cube2旋转角度:{mAngle}";
}

/// <summary>
/// 点击旋转Cube1看向Cube2
/// </summary>
public void OnRotateBtnClick()
{
Cube1.transform.Rotate(mRotateAxis, mAngle);
Debug.Log($"Cube1看向Cube2旋转方向:{mRotateAxis} 旋转角度:{mAngle}");
}
}

VectorCross

从上面的绘制可以看出,向量2-1叉乘向量1-3的结果是(0, 33.6, 0),即结果向量是向上的,这和Unity左手坐标系,a和b头尾相连顺时针则向外(即向上)是符合的。

Note:

  1. 点乘和叉乘一起时,叉乘优先计算
  2. 叉乘不满足交换律和结合律
  3. 判定叉乘A叉乘B方向时可以通过四指指向A向量,然后转向B向量,此时大拇指的方向就是叉乘向量方向

运用

点乘和叉乘最典型的运用就是通过点乘计算两个向量的夹角,通过叉乘计算两个向量的角度方向。

典型的问题就是已知玩家A和B的位置和朝向,如何让玩家A看向玩家B。

要解决上述问题,我们需要通过玩家A到玩家B位置的向量和玩家A的朝向向量来计算出他们的夹角和旋转方向。

接下来结合Unity实战,让我们看看,我们是如何通过点乘和叉乘计算出两个向量的旋转角度和方向的:

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
/*
* Description: VectorUsingStudyEditor.cs
* Author: TONYTANG
* Create Date: 2022/03/17
*/

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

/// <summary>
/// VectorUsingStudyEditor.cs
/// VectorUsingStudy的Editor绘制
/// </summary>
[CustomEditor(typeof(VectorUsingStudy))]
public class VectorUsingStudyEditor : Editor
{
******

/// <summary>
/// 绘制相关向量
/// </summary>
private void DrawVectorInfo()
{
if (Event.current.type == EventType.Repaint)
{
if (mCube1 != null && mCube2 != null && mCube3 != null)
{
var cube1Forward = mCube1.transform.forward;
var cube1ToCube2 = mCube2.transform.position - mCube1.transform.position;
var vectorDot = Vector3.Dot(cube1Forward, cube1ToCube2);
var radians = Mathf.Acos(vectorDot / (cube1Forward.magnitude * cube1ToCube2.magnitude));
var angle = Mathf.Rad2Deg * radians;
var vectorCross = Vector3.Cross(cube1Forward, cube1ToCube2);
vectorCross = (angle <= Mathf.Epsilon && angle >= -Mathf.Epsilon) ? cube1Forward : vectorCross;
Handles.color = mVectorColorProperty.colorValue;
Handles.ArrowHandleCap(1, mCube1.transform.position,
Quaternion.LookRotation(cube1Forward),
mCoordinateSystemLengthProperty.floatValue, EventType.Repaint);
Handles.ArrowHandleCap(1, mCube1.transform.position,
Quaternion.LookRotation(cube1ToCube2),
mCoordinateSystemLengthProperty.floatValue, EventType.Repaint);
Handles.ArrowHandleCap(1, mCube1.transform.position,
Quaternion.LookRotation(vectorCross),
mCoordinateSystemLengthProperty.floatValue, EventType.Repaint);
HandlesUtilities.DrawColorLable(mCube1.transform.position, $"夹角:{angle}", Color.green);
}
}
}
}
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
/*
* Description: VectorUsingStudy.cs
* Author: TONYTANG
* Create Date: 2022/03/17
*/

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

/// <summary>
/// VectorUsingStudy.cs
/// 向量点乘和叉乘实战学习
/// </summary>
public class VectorUsingStudy : MonoBehaviour
{
******

/// <summary>
/// 旋转角度
/// </summary>
private float mAngle;

/// <summary>
/// 旋转轴
/// </summary>
private Vector3 mRotateAxis;

private void Start()
{
RotateBtn.onClick.AddListener(OnRotateBtnClick);
var cube1Forward = Cube1.transform.forward;
var cube1ToCube2 = Cube2.transform.position - Cube1.transform.position;
var vectorDot = Vector3.Dot(cube1Forward, cube1ToCube2);
var radians = Mathf.Acos(vectorDot / (cube1Forward.magnitude * cube1ToCube2.magnitude));
mAngle = Mathf.Rad2Deg * radians;
mRotateAxis = Vector3.Cross(cube1Forward, cube1ToCube2);
var rotationDirection = mRotateAxis.y > 0 ? "顺时针" : "逆时针";
TipTxt.text = $"红色表示Cube1,绿色表示Cube2\n" +
$"Cube1世界坐标:{Cube1.transform.position.ToString()} 朝向:{Cube1.transform.forward.ToString()}\n" +
$"Cube2世界坐标系:{Cube2.transform.position.ToString()} 朝向:{Cube2.transform.forward.ToString()}\n" +
$"Cube1朝向:{cube1Forward.ToString()}\n" +
$"Cube1到Cube2向量:{cube1ToCube2.ToString()}\n" +
$"Cube1朝向 点乘 Cube1到Cube2向量 = {vectorDot}\n" +
$"Cube1朝向 叉乘 Cube1到Cube2向量 = {mRotateAxis.ToString()}\n" +
$"Cube1看向Cube2旋转方向:{rotationDirection}\n" +
$"Cube1看向Cube2旋转角度:{mAngle}";
}

/// <summary>
/// 点击旋转Cube1看向Cube2
/// </summary>
public void OnRotateBtnClick()
{
Cube1.transform.Rotate(mRotateAxis, mAngle);
Debug.Log($"Cube1看向Cube2旋转方向:{mRotateAxis} 旋转角度:{mAngle}");
}
}

RotationCaculation1

从上面的效果图可以看出,当Cube2在不同的位置时,我们通过点乘和叉乘成功算出了Cube1朝向向量到Cube1到Cube2位置向量的旋转角度和选择方向。

RotationCaculation2

通过点击Cube1看向Cube2,我们调用transform.Rotate(叉乘的向量, 点乘逆推的角度)我们可以看到,Cube1实现通过绕(0, -1.8, 0)选择158度实现了看向Cube2。

如果只是单纯的让玩家B看向玩家A,实战中我们并不会向上面一样根据公式的逆推实现旋转轴和旋转角度,而是直接使用Unity提供的API,比如transform.LookAt(玩家ATransform)即可实现玩家B看向玩家A的需求。但了解底层的实现原理以及叉乘点乘的实际用处还是很有必要的。

上面有一个细节要注意的是,Quaternion.LookRotation()不支持看向一个零向量,所以通过判定点旋转角度是否为0来判定是否Cube1朝向和Cube1连接Cube2的向量方向一致

更多关于向量的运算参考Vector3相关的接口使用,在后续实战中更多的学习运用,这里就不一一提及了。

Note:

  1. |aXb|为0表示两个向量平行
  2. 利用叉乘结果y大于0还是y小于0区分方向的同时,我们还能用于推断向量A在向量B的左侧还是右侧(大前提是两个向量在XZ平面)
  3. 想判定两个向量的前后关系,可以用点乘的正负结果来判定

矩阵

待添加……

欧拉角与四元数

待添加……

几何检测

待添加……

可见性检测

待添加……

数学概念

待添加……

学习总结

待添加……

Github

个人Github:

MathStudy

详细的Editor绘制学习参考:

UnityEditor知识

Reference

计算机图形学 5:齐次坐标与 MVP 矩阵变换

向量内积(点乘)和外积(叉乘)概念及几何意义

Introduction

Unity游戏开发过程中我会需要通过Editor相关工具来编写一些工具和可视化显示工等,用于辅助完成特定的功能和编辑器开发,本章节用于整理和学习Unity相关的Editor知识,好让自己对于Editor的相关知识有一个系统的学习和整理。

Editor绘制分类

Unity里的UI包含多种UI:

  1. UI Toolkit(最新一代的UI系统框架,高性能跨平台,基础Web技术)
  2. UGUI(老版的UI系统,2022年为止用的最多的官方UI系统)
  3. IMGUI(Immediate Mode Graphical User Interface,code-Driven的GUI系统)

而我们现在主要Editor用到的GUI暂时是指IMGUI,未来更多的学习中心是UI Toolkit(通吃Editor和运行时开发的新一代UI框架)。

这里UGUI在Editor篇章就不重点讲述,本文重点学习IMGUI和UI Toolkit(TODO)。

上述三中UI适用场景和适用人群,这里放两张官方的图片用于有个基础认知:

GUIEditorOrRuntime

GUIRolesAndSkillSets

详细的对比参考:

UI-system-compare

Note:

  1. UI Toolkit是官方主推的未来用于Editor和Runtime的新UI框架

IMGUI

IMGUI主要用于绘制我们Editor的自定义UI(比如自定义窗口UI,自定义面板UI,自定义场景UI等)

GUI

GUI主要由Type,Position和Content组成

  1. Type标识了GUI是什么类型的GUI(比如GUI.Label表示是一个标签)
  2. Position标识了GUI的显示位置和大小等信息(Rect)
  3. Content标识了GUI显示的内容(可以是一个文本可以使一个图片)

Type:

我们常用的GUI类型比如Lable(标签),Button(按钮),TextField(输入文本),Toggle(选择按钮)等

Position:

位置和大小信息主要通过Rect结构来表示位置和大小信息

Content:

GUI内容显示比如常用的文本和图标等形式

这里实现了一个简单的纯GUI的Unity学习项目:

UnityEditorStudyiEntryUI

Git地址见文章最后

Note:

  1. GUI的坐标系是左上角0,0

GUI Layout

GUI的排版分为两类:

  1. 固定排版
  2. 自动排版

固定排版:

固定排版是指我通过调用构建传递Rect信息的方式,显示的指明GUI的显示位置和大小信息。但这样的方式在我们不知道UI布局(比如动态布局动态大小)的时候就很难实现完美的UI布局。

接下来我们直接实战看下效果:

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
/*
* Description: FixLayoutDraw.cs
* Author: TONYTANG
* Create Date: 2022/03/06
*/

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

/// <summary>
/// FixLayoutDraw.cs
/// 固定排版绘制
/// </summary>
public class FixLayoutDraw : MonoBehaviour
{
private void OnGUI()
{
GUI.Label(new Rect(0f, 0f, 300f, 100f), $"固定排版文本位置信息:(0, 0)大小:(300, 100)");

GUI.BeginGroup(new Rect(Screen.width / 2 - 100, Screen.height / 2 - 100, 300f, 200f));
GUI.Box(new Rect(0, 25, 300, 50), "GUI组里的Box位置:(0, 25)大小:(300, 50)");
if(GUI.Button(new Rect(0, 100, 300, 50), "GUI组里的按钮位置:(0, 100)大小:(300, 50)"))
{
Debug.Log($"点击的GUI组里的按钮");
}
GUI.EndGroup();
}
}

FixLayoutGUI

Note:

  1. GUI Group可以实现GUI按组控制显示的功能

自动排版:

自动排版顾名思义不再需要向固定排版一样对于每一个组件都写死位置和大小信息,自动排版的组件(Type)会根据设置的相关信息进行自动适配。

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
/*
* Description: AutoLayoutDraw.cs
* Author: TONYTANG
* Create Date: 2022/03/06
*/

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

/// <summary>
/// AutoLayoutDraw.cs
/// 自动排版绘制
/// </summary>
public class AutoLayoutDraw : MonoBehaviour
{
private void OnGUI()
{
// 自动排版
if(GUILayout.Button("自动排版的第一个按钮"))
{
Debug.Log($"点击了自动排版的第一个按钮!");
}
// 带自动排版的自定义绘制区域1
GUILayout.BeginArea(new Rect(Screen.width / 2 - 150, Screen.height / 2 - 150, 300, 300));
if(GUILayout.Button("自动排版中心区域内的按钮1"))
{
Debug.Log($"点击了自动排版中心区域内的按钮1!");
}
if (GUILayout.Button("自动排版中心区域内的按钮2"))
{
Debug.Log($"点击了自动排版中心区域内的按钮2!");
}
GUILayout.EndArea();

// 带自动排版的自定义绘制区域2
GUILayout.BeginArea(new Rect(Screen.width - 300, Screen.height - 100, 300, 100));
// 指定右下角区域采用纵向自动布局
GUILayout.BeginVertical();
// 指定GUI按钮的宽度固定200
if (GUILayout.Button("自动排版右下角区域内的按钮1", GUILayout.Width(200f)))
{
Debug.Log($"点击了自动排版右下角区域内的按钮1!");
}
// 指定GUI按钮的宽度自适应
if (GUILayout.Button("自动排版右下角区域内的按钮2", GUILayout.ExpandWidth(true)))
{
Debug.Log($"点击了自动排版右下角区域内的按钮2!");
}
GUILayout.EndVertical();
GUILayout.EndArea();
}
}

AutoLayoutGUI

Note:

  1. 自动布局可以帮我们方便快速的完成UI布局适配而不需要写死所有组件的大小和位置

  2. GUILayout主要用于GUI的自动排版,而EditorGUILayout主要用于Edtior的GUI自动排版

  3. GUI.BeginChange()和GUI.EndChange()的配套使用可以检测指定GUI的值是否变化,做到检测变化响应后做指定的事情

GUISkin和GUIStyle

GUISkins are a collection of GUIStyles. Styles define the appearance of a GUI Control. You do not have to use a Skin if you want to use a Style.

从介绍可以看出GUISkins是GUIStyls的合集,GUIStyles定义了我们GUI的样式。

但如何得知Unity支持哪些GUIStyle了?

GUIStyle查看器工具(网上搜索到别人写的):

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
using UnityEngine;
using UnityEditor;
public class EditorStyleViewer : EditorWindow
{
private Vector2 scrollPosition = Vector2.zero;
private string search = string.Empty;
[MenuItem("Tools/GUI样式查看器")]
public static void Init()
{
EditorWindow.GetWindow(typeof(EditorStyleViewer));
}
void OnGUI()
{
GUILayout.BeginHorizontal("HelpBox");
GUILayout.Label("单击示例将复制其名到剪贴板", "label");
GUILayout.FlexibleSpace();
GUILayout.Label("查找:");
search = EditorGUILayout.TextField(search);
GUILayout.EndHorizontal();
scrollPosition = GUILayout.BeginScrollView(scrollPosition);
foreach (GUIStyle style in GUI.skin)
{
if (style.name.ToLower().Contains(search.ToLower()))
{
GUILayout.BeginHorizontal("PopupCurveSwatchBackground");
GUILayout.Space(7);
if (GUILayout.Button(style.name, style))
{
EditorGUIUtility.systemCopyBuffer = "\"" + style.name + "\"";
}
GUILayout.FlexibleSpace();
EditorGUILayout.SelectableLabel("\"" + style.name + "\"");
GUILayout.EndHorizontal();
GUILayout.Space(11);
}
}
GUILayout.EndScrollView();
}
}

GUIStyleViewer

Note:

  1. 我们可以右键创建一个GUISkin资源后加载指定GUISkin来控制我们的GUI显示风格

UIEvent

这里说的UIEvent主要是指玩家操作触发的一些事件信息,只有有了这些事件信息的我们才能编写对应的交互反馈。

Unity的事件信息统一由Event这个类封装,我们通过访问Event可以实现自定义的按钮响应和交互。

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
/*
* Description: EventDraw.cs
* Author: TONYTANG
* Create Date: 2022/03/06
*/

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

/// <summary>
/// EventDraw.cs
/// 事件响应绘制
/// </summary>
public class EventDraw : MonoBehaviour
{
/// <summary>
/// 事件信息
/// </summary>
private string mEventInfo;

/// <summary>
/// 键盘按下事件
/// </summary>
private string mKeyDownEvent;

/// <summary>
/// 鼠标点击事件
/// </summary>
private string mMouseDownEvent;

private void OnGUI()
{
mEventInfo = string.Empty;
mEventInfo = $"当前事件类型:{Event.current.type}";
if(Event.current.type == EventType.KeyDown && Event.current.keyCode != KeyCode.None)
{
mKeyDownEvent = $"按下过按键码:{Event.current.keyCode}";
}
if (Event.current.type == EventType.MouseDown)
{
if(Event.current.button == 0)
{
mMouseDownEvent = $"按下过鼠标:左键";
}
else if (Event.current.button == 1)
{
mMouseDownEvent = $"按下过鼠标:右键";
}
}
GUILayout.BeginArea(new Rect(Screen.width / 2 - 200, Screen.height / 2 - 200, 400, 400));
GUILayout.Label($"{mEventInfo}", GUILayout.Width(400), GUILayout.Height(20));
GUILayout.Label($"{mKeyDownEvent}", GUILayout.Width(400), GUILayout.Height(20));
GUILayout.Label($"{mMouseDownEvent}", GUILayout.Width(400), GUILayout.Height(20));
GUILayout.EndArea();
}
}

GUIEventGUI

IMGUI实战

了解了GUI相关的基础知识后,让我们来深入学习了解下GUI在Editor里的运用。

Editor个人接触到的按显示区域来分类的话,个人接触过以下几类:

  1. Scene场景辅助GUI绘制
  2. Editor纯工具窗口绘制
  3. Inspector面板绘制
  4. 其他Editor功能

Scene辅助GUI绘制

Scene的GUI绘制主要分为两种:

  1. Gizmos
  2. Handles

Gizmos

Gizmos主要用于绘制Scene场景里的可视化调试(比如绘制线,图标,cube等),不支持交互。

Note:

  1. Gizmos的代码需要卸载OnDrawGizmos()或OnDrawGizmosSelected()方法内,区别是前者是Scene场景一直显示,后者是只有包含该脚本的指定对象被选中时显示。

Handles

Handles主要用于绘制Scene场景里3D可交互UI(比如可拉伸坐标轴,按钮等)。

Note:

  1. Handles的绘制代码需要卸载OnSceneGUI()方法内
  2. Handles里依然支持GUI的2D GUI绘制,但需要把代码写在Hanldes.BeginGUI和Handles.EndGUI内。
实战
  1. Gizmos+Handles绘制Scene场景里的坐标系
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
/*
* Description: CoordinateSystemDraw.cs
* Author: TONYTANG
* Create Date: 2022/02/20
*/

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

/// <summary>
/// CoordinateSystemDraw.cs
/// 坐标系Gizmo绘制
/// </summary>
public class CoordinateSystemDraw : MonoBehaviour
{
/// <summary>
/// 坐标系原点
/// </summary>
[Header("坐标系原点")]
public Vector3 CoordinateCenterPoint = Vector3.zero;

/// <summary>
/// 坐标系长度
/// </summary>
[Header("坐标系长度")]
public float CoordinateSystemLength = 1.0f;

/// <summary>
/// Z轴颜色
/// </summary>
[Header("Z轴颜色")]
public Color ForwardAxisColor = Color.blue;

/// <summary>
/// X轴颜色
/// </summary>
[Header("X轴颜色")]
public Color RightAxisColor = Color.red;

/// <summary>
/// Y轴颜色
/// </summary>
[Header("Y轴颜色")]
public Color UpAxisColor = Color.green;

/// <summary>
/// XY平面颜色
/// </summary>
[Header("XY平面颜色")]
public Color ForwardPlaneColor = new Color(0, 0, 255, 48);

/// <summary>
/// ZY平面颜色
/// </summary>
[Header("ZY平面颜色")]
public Color RightPlaneColor = new Color(255, 0, 0, 48);

/// <summary>
/// XZ平面颜色
/// </summary>
[Header("XZ平面颜色")]
public Color UpPlaneColor = new Color(0, 255, 0, 48);

/// <summary>
/// XY轴平面大小
/// </summary>
private Vector3 ForwardPlaneSize = new Vector3(1, 1, 0.001f);

/// <summary>
/// ZY轴平面大小
/// </summary>
private Vector3 RightPlaneSize = new Vector3(0.001f, 1, 1);

/// <summary>
/// XZ轴平面大小
/// </summary>
private Vector3 UpPlaneSize = new Vector3(1, 0.001f, 1);

/// <summary>
/// 原始颜色
/// </summary>
private Color mOriginalColor;

void OnDrawGizmos()
{
DrawForwardPlane();
DrawRightPlane();
DrawUpPlane();
}

/// <summary>
/// 绘制XY平面
/// </summary>
private void DrawForwardPlane()
{
mOriginalColor = GUI.color;
Gizmos.color = ForwardPlaneColor;
ForwardPlaneSize.x = CoordinateSystemLength;
ForwardPlaneSize.y = CoordinateSystemLength;
Gizmos.DrawCube(CoordinateCenterPoint, ForwardPlaneSize);
Gizmos.color = mOriginalColor;
}

/// <summary>
/// 绘制ZY平面
/// </summary>
private void DrawRightPlane()
{
mOriginalColor = GUI.color;
Gizmos.color = RightPlaneColor;
RightPlaneSize.y = CoordinateSystemLength;
RightPlaneSize.z = CoordinateSystemLength;
Gizmos.DrawCube(CoordinateCenterPoint, RightPlaneSize);
Gizmos.color = mOriginalColor;
}

/// <summary>
/// 绘制XZ平面
/// </summary>
private void DrawUpPlane()
{
mOriginalColor = GUI.color;
Gizmos.color = UpPlaneColor;
UpPlaneSize.x = CoordinateSystemLength;
UpPlaneSize.z = CoordinateSystemLength;
Gizmos.DrawCube(CoordinateCenterPoint, UpPlaneSize);
Gizmos.color = mOriginalColor;
}
}
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
/*
* Description: CoordinateSystemDrawEditor.cs
* Author: TONYTANG
* Create Date: 2022/02/20
*/

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

/// <summary>
/// CoordinateSystemDrawEditor.cs
/// CoordinateSystemDraw的Editor绘制
/// </summary>
[CustomEditor(typeof(CoordinateSystemDraw))]
public class CoordinateSystemDrawEditor : Editor
{
/// <summary>
/// 坐标系原点属性
/// </summary>
SerializedProperty mCoordinateCenterPointProperty;

/// <summary>
/// 坐标系长度属性
/// </summary>
SerializedProperty mCoordinateSystemLengthProperty;

/// <summary>
/// Z轴颜色属性
/// </summary>
SerializedProperty mForwardAxisColorProperty;

/// <summary>
/// X轴颜色属性
/// </summary>
SerializedProperty mRightAxisColorProperty;

/// <summary>
/// Y轴颜色
/// </summary>
SerializedProperty mUpAxisColorProperty;

protected void OnEnable()
{
mCoordinateCenterPointProperty = serializedObject.FindProperty("CoordinateCenterPoint");
mCoordinateSystemLengthProperty = serializedObject.FindProperty("CoordinateSystemLength");
mForwardAxisColorProperty = serializedObject.FindProperty("ForwardAxisColor");
mRightAxisColorProperty = serializedObject.FindProperty("RightAxisColor");
mUpAxisColorProperty = serializedObject.FindProperty("UpAxisColor");
}

protected virtual void OnSceneGUI()
{
if(Event.current.type == EventType.Repaint)
{
Handles.color = mForwardAxisColorProperty.colorValue;
Handles.ArrowHandleCap(1, mCoordinateCenterPointProperty.vector3Value,
Quaternion.LookRotation(Vector3.forward),
mCoordinateSystemLengthProperty.floatValue, EventType.Repaint);

Handles.color = mRightAxisColorProperty.colorValue;
Handles.ArrowHandleCap(1, mCoordinateCenterPointProperty.vector3Value,
Quaternion.LookRotation(Vector3.right),
mCoordinateSystemLengthProperty.floatValue, EventType.Repaint);

Handles.color = mUpAxisColorProperty.colorValue;
Handles.ArrowHandleCap(1, mCoordinateCenterPointProperty.vector3Value,
Quaternion.LookRotation(Vector3.up),
mCoordinateSystemLengthProperty.floatValue, EventType.Repaint);
}
}
}

效果展示:

CustomCoordinatesGUI

  1. Gizmos绘制物体的标签和2D可交互按钮显示
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
/*
* Description: TagAndButtonDraw.cs
* Author: TONYTANG
* Create Date: 2022/02/21
*/

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

/// <summary>
/// TagAndButtonDraw.cs
/// 物体标签和交互按钮显示
/// </summary>
public class TagAndButtonDraw : MonoBehaviour
{
/// <summary>
/// 物体名
/// </summary>
[Header("物体名")]
public string Name = "GUI测试物体";

/// <summary>
/// 物体标签名
/// </summary>
[Header("物体标签名")]
public string TagName = "sv_icon_name3";

void OnDrawGizmos()
{
DrawTag();
}

/// <summary>
/// 绘制物体标签
/// </summary>
private void DrawTag()
{
Gizmos.DrawIcon(transform.position, TagName, false);
}
}
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
/*
* Description: TagAndButtonDrawEditor.cs
* Author: TONYTANG
* Create Date: 2022/02/21
*/

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

/// <summary>
/// TagAndButtonDrawEditor.cs
/// 物体也签和交互按钮显示的Editor绘制
/// </summary>
[CustomEditor(typeof(TagAndButtonDraw))]
public class TagAndButtonDrawEditor : Editor
{
/// <summary>
/// 物体名属性
/// </summary>
SerializedProperty mNameProperty;

protected void OnEnable()
{
mNameProperty = serializedObject.FindProperty("Name");
}

protected virtual void OnSceneGUI()
{
var tagAndButtonDraw = (TagAndButtonDraw)target;
if(tagAndButtonDraw == null)
{
return;
}
Handles.Label(tagAndButtonDraw.transform.position, mNameProperty.stringValue);
// Handles里绘制2D GUI需要在Handles.BeginGUI()和Handles.EndGUI()内
Handles.BeginGUI();
if(GUILayout.Button("测试按钮", GUILayout.Width(200f), GUILayout.Height(40f)))
{
Debug.Log($"物体按钮被点击!");
Selection.activeGameObject = tagAndButtonDraw.gameObject;
}
Handles.EndGUI();
}
}

效果展示:

GizmoGUI

关于GUI的Icon详细列表可以查询这里:

Unity Editor Built-in Icons

Editor窗口绘制

Editor的窗口绘制需要继承至EditorWindow类,然后在**OnGUI()**方法内利用GUI相关接口编写UI排版代码。

这一类代码工具代码写的比较多就不详细介绍了,主要是使用GUILayoutEditorGUILayout以及GUI相关的接口编写,使用MenuItem定义窗口UI入口,GenericMenu编写自定义菜单,Handles编写一些可视化线等。然后结合Event模块编写一些自定义的操作控制。

实战

典型例子1-资源打包工具窗口的编写:

效果展示:

AssetBundleCollectWindow

AssetBundleLoadManagerUIAfterDestroyWindow

详情参见:

AssetBundleManager

典型例子2-行为树编辑器编写:

自定义变量节点
自定义变量参数面板
所有自定义变量展示面板

节点编辑器操作响应

节点编辑器操作面板

行为树编辑器菜单

行为树节点窗口

行为树节点连线

行为树节点编辑器效果展示

详情参见:

BehaviourTreeForLua

Note:

  1. 主要注意GUI和GUILayout或EditorGUILayout在自动排版上有区分,前者通过自定义布局实现排版布局,后者通过自动排版实现排版布局。

Inspector面板绘制

Inspector的面板绘制跟Unity序列化相关,常规的Inspector默认显示是通过继承MonobehaviourScriptableObject结合标记Serializable(标记类或结构体需要序列化),SerializedFied(标记成员需要序列化),NonSerialized(标记成员不需要序列化)和HideInInspector(标记成员不显示在面板上)来实现字段序列化和显示控制。

详细的序列化支持规则参考:

Serialization rules

实战

基础版的序列化事例:

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
/*
* Description: CustomInspectorDraw.cs
* Author: TONYTANG
* Create Date: 2022/02/21
*/

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

/// <summary>
/// CustomInspectorDraw.cs
/// 自定义Inspector面板绘制
/// </summary>
public class CustomInspectorDraw : MonoBehaviour
{
/// <summary>
/// 序列化类
/// </summary>
[Serializable]
public class SerilizableClass
{
[Header("类的整形数据")]
public int ClassIntValue;

/// <summary>
/// 类隐藏不显示的字符串数据
/// </summary>
[HideInInspector]
[Header("类隐藏不显示的字符串数据")]
public string ClassHideInspectorStringValue;

/// <summary>
/// 类不序列化的布尔数据
/// </summary>
[NonSerialized]
[Header("类不序列化的布尔数据")]
public bool ClassNonSerializedBoolValue;
}

/// <summary>
/// 字符串数据
/// </summary>
[Header("字符串数据")]
public string StringValue;

/// <summary>
/// 布尔数据
/// </summary>
[Header("布尔数据")]
public bool BoolValue;

/// <summary>
/// GameObject对象数据
/// </summary>
[Header("GameObject对象数据")]
public GameObject GoValue;

/// <summary>
/// 整形数组数据
/// </summary>
[Header("整形数组数据")]
public int[] IntArrayValue;

/// <summary>
/// 序列化类成员
/// </summary>
[Header("序列化类成员")]
public SerilizableClass SerilizabledClass;
}

效果展示:

CustomInspectorGUI

如果我们需要自定义Inspector的面板显示,我们需要继承Editor类然后重写OnInspectorGUI接口进行自定义UI的代码编写,主要使用GUI,EditorGUI相关接口。

自定义Inspector事例:

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
/*
* Description: CustomInspectorDraw.cs
* Author: TONYTANG
* Create Date: 2022/02/21
*/

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

/// <summary>
/// CustomInspectorDraw.cs
/// 自定义Inspector面板绘制
/// </summary>
public class CustomInspectorDraw : MonoBehaviour
{
/// <summary>
/// 序列化类
/// </summary>
[Serializable]
public class SerilizableClass
{
[Header("类的整形数据")]
public int ClassIntValue;

/// <summary>
/// 类隐藏不显示的字符串数据
/// </summary>
[HideInInspector]
[Header("类隐藏不显示的字符串数据")]
public string ClassHideInspectorStringValue;

/// <summary>
/// 类不序列化的布尔数据
/// </summary>
[NonSerialized]
[Header("类不序列化的布尔数据")]
public bool ClassNonSerializedBoolValue;
}

/// <summary>
/// 字符串数据
/// </summary>
[Header("字符串数据")]
public string StringValue;

/// <summary>
/// 布尔数据
/// </summary>
[Header("布尔数据")]
public bool BoolValue;

/// <summary>
/// GameObject对象数据
/// </summary>
[Header("GameObject对象数据")]
public GameObject GoValue;

/// <summary>
/// 整形数组数据
/// </summary>
[Header("整形数组数据")]
public int[] IntArrayValue;

/// <summary>
/// 序列化类成员
/// </summary>
[Header("序列化类成员")]
public SerilizableClass SerilizabledClass;
}
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
/*
* Description: CustomInspectorDrawEditor.cs
* Author: TONYTANG
* Create Date: 2022/02/21
*/

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

/// <summary>
/// CustomInspectorDrawEditor.cs
/// CustomInspectorDraw自定义Inspector
/// </summary>
[CustomEditor(typeof(CustomInspectorDraw))]
public class CustomInspectorDrawEditor : Editor
{
/// <summary>
/// 字符串数据属性
/// </summary>
SerializedProperty StringValueProperty;

/// <summary>
/// 布尔数据属性
/// </summary>
SerializedProperty BoolValueProperty;

/// <summary>
/// GameObject对象数据属性
/// </summary>
SerializedProperty GoValueProperty;

/// <summary>
/// 整形数组数据属性
/// </summary>
SerializedProperty IntArrayValueProperty;

/// <summary>
/// 序列化类成员属性
/// </summary>
SerializedProperty SerilizabledClassProperty;

/// <summary>
/// 序列化类成员属性
/// </summary>
SerializedProperty ClassIntValueProperty;

/// <summary>
/// 序列化类成员属性
/// </summary>
SerializedProperty ClassHideInspectorStringValueProperty;

private void OnEnable()
{
StringValueProperty = serializedObject.FindProperty("StringValue");
BoolValueProperty = serializedObject.FindProperty("BoolValue");
GoValueProperty = serializedObject.FindProperty("GoValue");
IntArrayValueProperty = serializedObject.FindProperty("IntArrayValue");
SerilizabledClassProperty = serializedObject.FindProperty("SerilizabledClass");
ClassIntValueProperty = SerilizabledClassProperty.FindPropertyRelative("ClassIntValue");
ClassHideInspectorStringValueProperty = SerilizabledClassProperty.FindPropertyRelative("ClassHideInspectorStringValue");
}

public override void OnInspectorGUI()
{
// 确保对SerializedObject和SerializedProperty的数据修改每帧同步
serializedObject.Update();

EditorGUILayout.BeginVertical();
if(GUILayout.Button("重置所有值", GUILayout.ExpandWidth(true), GUILayout.Height(20f)))
{
StringValueProperty.stringValue = string.Empty;
BoolValueProperty.boolValue = false;
GoValueProperty.objectReferenceValue = null;
IntArrayValueProperty.arraySize = 0;
ClassIntValueProperty.intValue = 0;
ClassHideInspectorStringValueProperty.stringValue = string.Empty;
}
EditorGUILayout.PropertyField(StringValueProperty);
EditorGUILayout.PropertyField(BoolValueProperty);
EditorGUILayout.PropertyField(GoValueProperty);
EditorGUILayout.PropertyField(IntArrayValueProperty);
EditorGUILayout.PropertyField(SerilizabledClassProperty, true);
EditorGUILayout.EndVertical();

// 确保对SerializedObject和SerializedProperty的数据修改写入生效
serializedObject.ApplyModifiedProperties();
}
}

效果展示:

CustomInspectorDraw

如果想实现通用的属性重置,可以参考SerializedObject(比如GetIteractor())和SerializedProperty(比如propertyType和propertyPath以及XXXValue等)相关接口实现遍历重置。

通过重写Editor的OnInspectorGUI()方法确实实现了自定义Inspector显示,但每一个属性的显示都要通过编写自定义UI代码的方式去展示重用率低同时重复劳动多。

接下来这里要讲的是通过自定义属性标签实现面向标签的自定义Inspector绘制控制,从而实现高度的可重用属性标签

自定义Inspector属性绘制标签由两部分组成:

  1. 自定义标签继承至PropertyAttribute(控制自定义属性标签的值)
  2. 自定义标签绘制继承至PropertyDrawer(控制自定义属性标签的显示绘制)

比如我们想实现一个类似Range标签和纹理Preview显示的标签功能:

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
/*
* Description: TRangeAttribute.cs
* Author: TONYTANG
* Create Date: 2022/02/21
*/

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

/// <summary>
/// TRangeAttribute.cs
/// 数值范围属性标签
/// </summary>
public class TRangeAttribute : PropertyAttribute
{
/// <summary>
/// 最小值
/// </summary>
public readonly float MinValue;

/// <summary>
/// 最大值
/// </summary>
public readonly float MaxValue;

public TRangeAttribute(float minValue, float maxValue)
{
MinValue = minValue;
MaxValue = maxValue;
}
}
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
/*
* Description: TRangeDrawer.cs
* Author: TONYTANG
* Create Date: 2022/02/21
*/

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

/// <summary>
/// IntRangeDrawer.cs
/// 数值范围绘制
/// </summary>
[CustomPropertyDrawer(typeof(TRangeAttribute))]
public class TRangeDrawer : PropertyDrawer
{
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
var tRangeAttribute = (TRangeAttribute)attribute;
if(property.propertyType == SerializedPropertyType.Integer)
{
EditorGUI.IntSlider(position, property, (int)tRangeAttribute.MinValue, (int)tRangeAttribute.MaxValue, label);
}
else if(property.propertyType == SerializedPropertyType.Float)
{
EditorGUI.Slider(position, property, tRangeAttribute.MinValue, tRangeAttribute.MaxValue, label);
}
else
{
EditorGUILayout.LabelField(label, "请使用TRange到float或int上!");
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*
* Description: TPreviewAttribute.cs
* Author: TONYTANG
* Create Date: 2022/02/21
*/

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

/// <summary>
/// TPreviewAttribute.cs
/// 可预览的标签
/// </summary>
public class TPreviewAttribute : PropertyAttribute
{
public TPreviewAttribute()
{

}
}
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
/*
* Description: TPreviewDrawer.cs
* Author: TONYTANG
* Create Date: 2022/02/21
*/

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

/// <summary>
/// TPreviewDrawer.cs
/// 预览绘制
/// </summary>
[CustomPropertyDrawer(typeof(TPreviewAttribute))]
public class TPreviewDrawer : PropertyDrawer
{
/// <summary>
/// 调整整体高度
/// </summary>
/// <param name="property"></param>
/// <param name="label"></param>
/// <returns></returns>
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
{
return base.GetPropertyHeight(property, label) + 64f;
}

public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
EditorGUI.BeginProperty(position, label, property);
EditorGUI.PropertyField(position, property, label);

Texture2D previewTexture = GetAssetPreview(property);
if (previewTexture != null)
{
Rect previewRect = new Rect()
{
x = position.x + GetIndentLength(position),
y = position.y + EditorGUIUtility.singleLineHeight,
width = position.width,
height = 64
};
GUI.Label(previewRect, previewTexture);
}
EditorGUI.EndProperty();
}

/// <summary>
/// 获取显示缩进间隔
/// </summary>
/// <param name="sourceRect"></param>
/// <returns></returns>
private float GetIndentLength(Rect sourceRect)
{
Rect indentRect = EditorGUI.IndentedRect(sourceRect);
float indentLength = indentRect.x - sourceRect.x;

return indentLength;
}

/// <summary>
/// 获取Asset预览显示纹理
/// </summary>
/// <param name="property"></param>
/// <returns></returns>
private Texture2D GetAssetPreview(SerializedProperty property)
{
if (property.propertyType == SerializedPropertyType.ObjectReference)
{
if (property.objectReferenceValue != null)
{
Texture2D previewTexture = AssetPreview.GetAssetPreview(property.objectReferenceValue);
return previewTexture;
}
return null;
}
return 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
/*
* Description: InspectorPropertyDraw.cs
* Author: TONYTANG
* Create Date: 2022/02/21
*/

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

/// <summary>
/// InspectorPropertyDraw.cs
/// 自定义属性标签绘制
/// </summary>
public class InspectorPropertyDraw : MonoBehaviour
{
/// <summary>
/// 整形数值
/// </summary>
[TRange(0, 100)]
[Header("整形数值")]
public int IntValue;

/// <summary>
/// float数值
/// </summary>
[TRange(0, 100)]
[Header("float数值")]
public float FloatValue;

/// <summary>
/// 预览的预制件
/// </summary>
[TPreview]
[Header("预览的预制件")]
public GameObject PreviewPrefab;
}

效果展示:

CustomInspectorPropertyDraw

以上两个自定义属性显示标签主要参考:

Property Drawers

Unity 拓展编辑器入门指南

通过上面的基础学习,我们对于自定义Inspector面板的绘制就有基础的了解了,详细深入的学习在实战中进一步学习了解,这里就不再一一赘述。

Note:

  1. Unity默认不支持序列化Dictionary和多维数组等,需要自己通过ISerializationCallbackReceiver接口进行自定义序列化支持
  2. Unity不支持序列化null以及多态的数据
  3. 尽量使用SerializedObject和SerializedProperty(泛型设计支持)进行Inspector编写,支持Undo系统,不会有嵌套预制件保存问题
  4. SerializedObject.Update()的调用是为了确保数据修改同步,SerializedObject.ApplyModifiedProperties()是为了对于数据的修改写入生效。总结在OnInspectorGUI()方法最前面调用serializedObject.Update()在最后调用serializedObject.ApplyModifiesdProperties()确保对于SerializedObject和SerializedProperty的修改和读取正确。

TreeView绘制

基础知识

TreeView是一个比较特殊的绘制UI,他可以以树状结构来显示我们的指定数据信息(这个个人平时用的不多作为学习了解)

首先让我们看下官网对TreeView的介绍:
TreeView is an IMGUI control used to display hierarchical data that you can expand and collapse. Use TreeView to create highly customizable list views and multi-column tables for Editor windows, which you can use alongside other IMGUI controls and components.
可以看到TreeView主要用于实现可以自定义快捷操作的多行多列窗口显示的组件。

让我们先了解一下TreeView里几个重要的类,方法和相关概念:

TreeView构建流程图:
TreeViewFlowChart

在了解官方更多抽象之前,让我们先了解几个TreeView里的基本但很重要的概念。

  • TreeView表示我们想要构建树状网格结构显示的基类抽象。

  • TreeView里的单元格由TreeViewItem定义,TreeViewItem表里树状结构里的每一条单元数据。

  • TreeViewItem里的id必须唯一,用于查询TreeViewItem的状态数据(TreeViewState),depth标识着单条数据在树状结构里的第几层(同时也标识这TreeViewItem之间的深度即父子关系),displayName标识着TreeViewItem的显示名字。

  • TreeViewState记录着TreeViewItem的一些操作序列化数据(e.g. 比如选择状态,展开状态,滚动状态等)。

  • MultiColumnHeader是TreeView支持多列显示的抽象类。

  • MultiColumnHeaderState跟TreeViewState类似,是用于构建MultiColumnHeader的状态数据。

从上面的基础介绍可以看出,简单的TreeView对于数据单元的构建是通过TreeViewItem封装而来,而TreeViewItem能封装的数据仅限于id和displayName,这样对于我们快速访问自定义数据并不友好,个人猜测这也是为什么官方为设计泛型TreeView的原因。

Note:

  1. BuildRoot在编辑器每次Reload时会调用。BuildRows在每次BuildRoot调用后以及发生折叠展开操作时。
  2. TreeView的初始化发生在每次调用TreeView的Reload方法时。
深入学习

接下来让我们看看官方事例是如何抽象泛型TreeView的。

让我们先来简单理解下各个抽象类的含义:

  • TreeElement – 自定义TreeViewItem的数据定义基类抽象,负责抽象需要序列化的数据以及TreeViewItem所需的父子概念。

  • TreeModel – 泛型定义,用于封装所有的TreeElement数据,抽象出跟节点数据概念以及唯一ID获取以及数据添加删除等接口。

  • TreeElementUtility – TreeElement数据相关操作辅助方法(e.g. 从数据列表构建TreeElement树状结构或从根节点反向构建数据列表等)。

  • TreeViewItem – 泛型定义,抽象不同数据类型的TreeElement封装访问。

  • TreeViewWithTreeModel – 泛型定义,抽象不同数据类型的TreeModel的TreeView封装访问以及通用的数据添加以及搜索等功能。

实战实现

以下代码根据官方TreeView多列事例(MultiColumnWindow)代码学习编写,但部分类名有所更改,请自行对上官方定义。

接下来看下实战我们怎么基于官方的泛型抽象,实现自定义一个消息统计树状结构展示以及增删改查的。

先看下本人的类定义:

  • MessageAsset(继承至ScriptableObject) – 消息统计基础数据定义,用于填充一些基础的消息统计数据(e.g. 消息黑名单,消息介绍等)。
  • MessageTreeAsset(继承至ScriptableObject) – 消息TreeView的原始数据Asset结构定义。
  • MessageTreeAssetEditor(继承至Editor) – 自定义MessageTreeAsset的Inspector面板显示(支持增删以及查看基础数据等操作)。
  • MessageTreeViewElement(继承至TreeViewElement) – 消息TreeView的单条数据结构定义。
  • MessageWindow(继承至EditorWindow) – 消息辅助窗口的窗口实现。
  • MessageTreeView(继承至TreeViewWithModel) – 实现泛型的TreeView定义,将MessageTreeViewElement作为自定义数据结构。

先直接放几张效果图:

MessageStatisticUI

MessagePreviewUI

MessageSimulationUI

MessageTreeAssetEditorUI

TreeView的泛型实现和自定义Inspector是第二张和第四张效果图。

本人实现了自定义MessageTreeeViewElement数据结构的TreeView展示。

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
/*
* Description: MessageTreeViewElement.cs
* Author: TONYTANG
* Create Date: 2022/04/22
*/

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

/// <summary>
/// MessageTreeViewElement.cs
/// 消息树形数据结构
/// </summary>
[Serializable]
public class MessageTreeViewElement : TreeViewElement
{
/// <summary>
/// 消息类型
/// </summary>
public enum MessageType
{
Request = 0,
Push,
}

/// <summary>
/// 消息名
/// </summary>
[Header("消息名")]
public string MsgName;

/// <summary>
/// 消息类型
/// </summary>
[Header("消息类型")]
public MessageTreeViewElement.MessageType MsgType;

/// <summary>
/// 消息内容
/// </summary>
[Header("消息内容")]
public string MsgContent;

/// <summary>
/// 消息注释
/// </summary>
[Header("消息注释")]
public string MsgAnnotation;

/// <summary>
/// 带参构造函数
/// </summary>
/// <param name="id"></param>
/// <param name="depth"></param>
/// <param name="name"></param>
public MessageTreeViewElement(int id, int depth, string name) : base(id, depth, name)
{
MsgName = name;
}

/// <summary>
/// 带参构造函数
/// </summary>
/// <param name="id">唯一id</param>
/// <param name="depth">深度值</param>
/// <param name="name">显示名字</param>
/// <param name="msgType">消息类型</param>
/// <param name="msgContent">消息内容</param>
/// <param name="msgAnnotation">消息注释</param>
public MessageTreeViewElement(int id, int depth, string name, MessageType msgType, string msgContent, string msgAnnotation) : base(id, depth, name)
{
MsgName = name;
MsgType = msgType;
MsgContent = msgContent;
MsgAnnotation = msgAnnotation;
}
}

通过使MessageTreeView继承TreeViewWithTreeModel实现泛型的TreeView自定义构建和行列展示以及搜索排序等功能。

接下来我将细节展示我是如何实现TreeView的自定义数据构建和行列展示以及搜索排序等功能的。

为了支持多列显示,我们必须传递MultiColumnHeader来构建MessageTreeView:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/// <summary>
/// 带参构造函数
/// </summary>
/// <param name="state"></param>
/// <param name="multicolumnHeader"></param>
/// <param name="model"></param>
public MessageTreeView(TreeViewState state, MultiColumnHeader multicolumnHeader, TreeModel<MessageTreeViewElement> model) : base(state, multicolumnHeader, model)
{
columnIndexForTreeFoldouts = 0;
showAlternatingRowBackgrounds = true;
showBorder = true;
// 监听排序设置变化
multicolumnHeader.sortingChanged += OnSortingChanged;

Reload();
}

多列的数据状态MultiColumnHeaderState通过自定义方法构建返回:

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
/// <summary>
/// 创建默认的多列显示状态数据
/// </summary>
/// <returns></returns>
public static MultiColumnHeaderState CreateDefaultMultiColumnHeaderState()
{
var columns = new[]
{
new MultiColumnHeaderState.Column
{
headerContent = new GUIContent("消息名", "消息名"),
contextMenuText = "消息名",
headerTextAlignment = TextAlignment.Center,
sortedAscending = true,
sortingArrowAlignment = TextAlignment.Right,
width = 200,
minWidth = 200,
maxWidth = 250,
autoResize = false,
allowToggleVisibility = false
},
new MultiColumnHeaderState.Column
{
headerContent = new GUIContent(EditorGUIUtility.FindTexture("FilterByLabel"), "消息类型"),
contextMenuText = "消息类型",
headerTextAlignment = TextAlignment.Center,
sortedAscending = true,
sortingArrowAlignment = TextAlignment.Right,
width = 40,
minWidth = 40,
maxWidth = 60,
autoResize = false,
allowToggleVisibility = false
},
new MultiColumnHeaderState.Column
{
headerContent = new GUIContent("消息注释", "消息注释"),
contextMenuText = "消息介绍",
headerTextAlignment = TextAlignment.Center,
sortingArrowAlignment = TextAlignment.Right,
width = 200,
minWidth = 200,
maxWidth = 250,
autoResize = false,
allowToggleVisibility = false,
},
new MultiColumnHeaderState.Column
{
headerContent = new GUIContent("消息内容", "消息内容"),
contextMenuText = "消息内容",
headerTextAlignment = TextAlignment.Center,
sortingArrowAlignment = TextAlignment.Right,
width = 120,
minWidth = 120,
maxWidth = 150,
autoResize = false,
allowToggleVisibility = false,
},
new MultiColumnHeaderState.Column
{
headerContent = new GUIContent("删除", "删除"),
contextMenuText = "删除",
headerTextAlignment = TextAlignment.Center,
sortingArrowAlignment = TextAlignment.Right,
width = 80,
minWidth = 80,
maxWidth = 80,
autoResize = false,
allowToggleVisibility = false,
}
};
var state = new MultiColumnHeaderState(columns);
return state;
}

MessageTreeView通过重写BuildRoot,BuildRows和RowGUI接口实现根节点构建以及只构建可见的行数据和显示:

TreeViewWithModel.cs

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
/// <summary>
/// 构建TreeView根节点
/// </summary>
/// <returns></returns>
protected override TreeViewItem BuildRoot()
{
return new TreeViewItemGeneric<T>(TreeModel.Root.ID, -1, TreeModel.Root.Name, TreeModel.Root);
}

/// <summary>
/// 构建TreeView单行数据节点
/// </summary>
/// <param name="root"></param>
/// <returns></returns>
protected override IList<TreeViewItem> BuildRows(TreeViewItem root)
{
if (TreeModel.Root == null)
{
Debug.LogError("TreeModel Root is null. Did you call SetData()?");
}

RowList.Clear();
if (!string.IsNullOrEmpty(searchString))
{
Search(TreeModel.Root, searchString, RowList);
}
else
{
if (TreeModel.Root.HasChild())
{
AddChildrenRecursive(TreeModel.Root, 0, RowList);
}
}

// 自动构建节点深度信息
SetupParentsAndChildrenFromDepths(root, RowList);

return RowList;
}

/// <summary>
/// 递归添加子节点
/// </summary>
/// <param name="parent"></param>
/// <param name="depth"></param>
/// <param name="newRows"></param>
void AddChildrenRecursive(T parent, int depth, IList<TreeViewItem> newRows)
{
foreach (T child in parent.ChildList)
{
var item = new TreeViewItemGeneric<T>(child.ID, depth, child.Name, child);
newRows.Add(item);

if (child.HasChild())
{
if (IsExpanded(child.ID))
{
AddChildrenRecursive(child, depth + 1, newRows);
}
else
{
item.children = CreateChildListForCollapsedParent();
}
}
}
}

MessageTreeView.cs

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
/// <summary>
/// 自定义构建行显示
/// </summary>
/// <param name="root"></param>
/// <returns></returns>
protected override IList<TreeViewItem> BuildRows(TreeViewItem root)
{
var rows = base.BuildRows(root);
SortIfNeeded(root, rows);
return rows;
}

/// <summary>
/// 自定义TreeView每行显示
/// </summary>
/// <param name="args"></param>
protected override void RowGUI(RowGUIArgs args)
{
var item = (TreeViewItemGeneric<MessageTreeViewElement>)args.item;

// 只构建可见的TreeView Row
for (int i = 0; i < args.GetNumVisibleColumns(); ++i)
{
CellGUI(args.GetCellRect(i), item, (MessageTreeColumns)args.GetColumn(i), ref args);
}
}

/// <summary>
/// 单行显示
/// </summary>
/// <param name="cellRect"></param>
/// <param name="item"></param>
/// <param name="column"></param>
/// <param name="args"></param>
void CellGUI(Rect cellRect, TreeViewItemGeneric<MessageTreeViewElement> item, MessageTreeColumns column, ref RowGUIArgs args)
{
// Center cell rect vertically (makes it easier to place controls, icons etc in the cells)
CenterRectUsingSingleLineHeight(ref cellRect);

switch (column)
{
case MessageTreeColumns.MessageName:
// 显示初始折叠和描述信息
base.RowGUI(args);
break;
case MessageTreeColumns.MessageType:
var iconTexture = item.Data.MsgType == MessageTreeViewElement.MessageType.Push ? PushIconTexture : PullIconTexture;
GUI.DrawTexture(cellRect, iconTexture, ScaleMode.ScaleToFit);
break;
case MessageTreeColumns.MessageAnnotation:
item.Data.MsgAnnotation = EditorGUI.TextField(cellRect, item.Data.MsgAnnotation);
break;
case MessageTreeColumns.MessageContent:
if (GUI.Button(cellRect, "编辑消息内容"))
{
var messageWindow = MessageWindow.OpenMessageWindow();
messageWindow.SetSelectedMessageSimulation(item.Data);
messageWindow.SelectMessageTag(MessageWindow.MessageTag.MessageSimulation);
}
break;
case MessageTreeColumns.MessageDelete:
if (GUI.Button(cellRect, "-"))
{
TreeModel.RemoveElementByID(item.Data.ID);
}
break;
}
}

上面构建自定义行数据和展示用到了几个比较重要的接口:

IsExpanded() – 判定指定数据ID节点是否展开

CreateChildListForCollapsedParent() – 为没展开的数据节点创建一个假的子节点,避免构建所有子数据节点

SetupParentsAndChildrenFromDepths() – 通过根节点和自定义构建的行数据节点,自动进行深度以及父子关系关联。

为了支持列排序设置,通过在MessageTreeView构造函数里监听MultiColumnHeader.sortingChanged接口实现自定义排序:

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
// 监听排序设置变化
multicolumnHeader.sortingChanged += OnSortingChanged;

/// <summary>
/// 排序回调
/// </summary>
/// <param name="multiColumnHeader"></param>
void OnSortingChanged(MultiColumnHeader multiColumnHeader)
{
SortIfNeeded(rootItem, GetRows());
}

/// <summary>
/// 排序
/// </summary>
/// <param name="root"></param>
/// <param name="rows"></param>
private void SortIfNeeded(TreeViewItem root, IList<TreeViewItem> rows)
{
if (rows.Count <= 1)
{
return;
}

// 没有设置排序的列
if (multiColumnHeader.sortedColumnIndex == -1)
{
return;
}

SortByMultipleColumns();
TreeToList(root, rows);
Repaint();
}

/// <summary>
/// 根据多列设置排序
/// </summary>
void SortByMultipleColumns()
{
var sortedColumns = multiColumnHeader.state.sortedColumns;

if (sortedColumns.Length == 0)
{
return;
}
// 暂时只根据单个设置排序
SortByColumnIndex(0);
}

/// <summary>
/// 根据指定列索引排序
/// </summary>
/// <param name="index"></param>
private void SortByColumnIndex(int index)
{
var sortedColumns = multiColumnHeader.state.sortedColumns;
if (sortedColumns.Length == 0 || sortedColumns.Length <= index)
{
return;
}
if (!mSortOptionsMap.ContainsKey(sortedColumns[index]))
{
return;
}
var childTreeViewItems = rootItem.children.Cast<TreeViewItemGeneric<MessageTreeViewElement>>();
SortOption sortOption = mSortOptionsMap[sortedColumns[index]];
bool ascending = multiColumnHeader.IsSortedAscending(sortedColumns[index]);
switch (sortOption)
{
case SortOption.MsgName:
if (ascending)
{
childTreeViewItems = childTreeViewItems.OrderBy(treeViewItemGeneric => treeViewItemGeneric.Data.MsgName);
}
else
{
childTreeViewItems = childTreeViewItems.OrderByDescending(treeViewItemGeneric => treeViewItemGeneric.Data.MsgName);
}
break;
case SortOption.MsgType:
if (ascending)
{
childTreeViewItems = childTreeViewItems.OrderBy(treeViewItemGeneric => treeViewItemGeneric.Data.MsgType);
}
else
{
childTreeViewItems = childTreeViewItems.OrderByDescending(treeViewItemGeneric => treeViewItemGeneric.Data.MsgType);
}
break;
}
rootItem.children = childTreeViewItems.Cast<TreeViewItem>().ToList();
}

/// <summary>
/// 构建
/// </summary>
/// <param name="root"></param>
/// <param name="result"></param>
private void TreeToList(TreeViewItem root, IList<TreeViewItem> result)
{
if (root == null)
{
throw new NullReferenceException("不能传空根节点!");
}
if (result == null)
{
throw new NullReferenceException("不能传空结果列表!");
}

result.Clear();

if (root.children == null)
{
return;
}

Stack<TreeViewItem> stack = new Stack<TreeViewItem>();
for (int i = root.children.Count - 1; i >= 0; i--)
{
stack.Push(root.children[i]);
}

while (stack.Count > 0)
{
TreeViewItem current = stack.Pop();
result.Add(current);

if (current.hasChildren && current.children[0] != null)
{
for (int i = current.children.Count - 1; i >= 0; i--)
{
stack.Push(current.children[i]);
}
}
}
}

为了支持树状消息自定义搜索展示,构建SearchField并绑定TreeView的SetFocusAndEnsureSelectedItem接口:

MessageWindow.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
mSearchField = new SearchField();
// 将搜索栏的输入回调和TreeView关联方便搜索检测
mSearchField.downOrUpArrowKeyPressed += mMessageTreeView.SetFocusAndEnsureSelectedItem;

/// <summary>
/// 绘制搜索区域
/// </summary>
///<param name="rect">绘制区域信息</param>
private void DrawSearchBarArea(Rect rect)
{
if(mMessageTreeView != null)
{
// 显示关联TreeView的搜索栏
mMessageTreeView.searchString = mSearchField.OnGUI(rect, mMessageTreeView.searchString);
}

}

TreeViewWithTreeModel.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// <summary>
/// 构建TreeView单行数据节点
/// </summary>
/// <param name="root"></param>
/// <returns></returns>
protected override IList<TreeViewItem> BuildRows(TreeViewItem root)
{
***

// 支持搜索功能
if (!string.IsNullOrEmpty(searchString))
{
Search(TreeModel.Root, searchString, RowList);
}

***
}

为了操作方便,我还监听了MessageTreeAsset的双击打开操作,快速打开窗口进行消息预览:

MessageWindow.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// <summary>
/// 监听Asset双击打开
/// </summary>
/// <param name="instanceID"></param>
/// <param name="line"></param>
/// <returns></returns>
[OnOpenAsset]
public static bool OnOpenAsset(int instanceID, int line)
{
var treeAsset = EditorUtility.InstanceIDToObject(instanceID) as MessageTreeAsset;
if (treeAsset != null)
{
var window = OpenMessageWindow();
window.SetTreeAsset(treeAsset);
window.SelectMessageTag(MessageTag.MessagePreview);
return true;
}
return false;
}

TreeElementUtility.cs负责实现二楼TreeViewElement相关的几个比较重要的方法接口:

TreeElementUtility.cs

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
/// <summary>
/// 将树形根节点转换到数据列表
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="root"></param>
/// <param name="result"></param>
public static void TreeToList<T>(T root, IList<T> result) where T : TreeViewElement
{
******
}

/// <summary>
/// 从数据列表里生成TreeView
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="list"></param>
/// <returns>返回根节点</returns>
public static T ListToTree<T>(IList<T> list) where T : TreeViewElement
{
******
}


/// <summary>
/// 检查数据列表信息
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="list"></param>
public static void ValidateDepthValues<T>(IList<T> list) where T : TreeViewElement
{
******
}

/// <summary>
/// 查找TreeView列表的根数据
/// </summary>
/// <param name="treeViewElementList"></param>
/// <returns></returns>
public static T FindRootTreeViewElement<T>(IList<T> treeViewElementList) where T : TreeViewElement
{
******
}

/// <summary>
/// 更新节点深度信息
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="root"></param>
public static void UpdateDepthValues<T>(T root) where T : TreeViewElement
{
******
}

完整代码链接:

EditorUIStudy

上述Git还包含了其他Editor相关GUI学习的代码,TreeView的入口为Tools->消息辅助窗口。

UI Toolkit

本章节内容比较多,单独提一篇来记录学习,详情参考:

UIToolkit学习

UGUI

UGUI是UIToolkit之前出的一套用于运行时的UI框架,本篇章主要学习Editor相关的GUI,这里就不深入学习讲解了。

学习总结

  1. Gizmos主要用于绘制Scene场景里的可视化调试(比如绘制线,图标,cube等),不支持交互。
  2. Handles主要用于绘制Scene场景里3D可交互UI(比如可拉伸坐标轴,按钮等)。
  3. GUI的坐标系是左上角0,0
  4. GUISkins是GUIStyls的合集,GUIStyles定义了我们GUI的样式

Editor数据存储

这里说的Editor的数据存储主要是针对Unity概念,而非常规的序列化反序列化方案。

在了解数据存储之前,我们需要了解Unity里关于序列化的一些概念和规则。

Unity序列化规则

字段序列化规则:

  1. public或者有SerializeField标签
  2. 非static
  3. 非const
  4. 非readonly
  5. 基础数据类型(e.g. int,float,double,bool string ……)
  6. 枚举
  7. Fixed-size buffers(?不太清楚是指bit,byte还是啥)
  8. Unity一些内置类型(e.g. Vector2,Vector3,Rect,Matrix4x4,Color,AnimationCurve)
  9. 标记有Serializable的自定义class
  10. 符合以上条件类型的列表或数组

详细规则见:

Script serialization

注意事项:

  1. Unity不支持序列化多层数据结构类型(e.g. 多维数组,不定长数组,Dictionary,nested container types(?这个没太明白是指什么类型))

解决方案:

  1. 通过class封装,定义成常规支持的数据结构
  2. 通过自定义序列化接口ISerializationCallbackReceiver,编写自定义序列化代码

序列化自定义Class

序列化自定义Class必须满足一下两个条件:

  1. 有Serializable标签
  2. 非static Class

Unity序列化自定义Class有两种方式:

  1. By Reference(按引用,C#里引用的概念,确保同一个对象序列化反序列化指向同一个对象)
  2. By Value(按值,类似C#里值类型的概念,只序列化值不保持引用关系,即反序列化后引用会丢失只有值会还原)

默认Unity序列化非继承UntiyEngine.Object的自定义Class方式是By Value,要想支持By Reference需要添加SerializeReference标签(在需要序列化的自定义Class成员上)

SerializeReference主要支持以下几种情况:

  1. 字段可以为null
  2. 序列化可以保持引用,反序列化后可以指向同一对象
  3. 实现定义父类类型但序列化反序列化子类数据的多态序列化反序列化

让我们实战理解一下以上概念:

BaseCustomClass.cs(自定义Class基类)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// BaseCustomClass.cs
/// 自定义Class基类
/// </summary>
[Serializable]
public class BaseCustomClass
{
/// <summary>
/// 名字
/// </summary>
[Header("名字")]
public string Name;

public BaseCustomClass(string name)
{
Name = name;
}
}

ChildCustomClass.cs(自定义子类)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// ChildCustomClass.cs
/// 自定义子类
/// </summary>
[Serializable]
public class ChildCustomClass : BaseCustomClass
{
/// <summary>
/// 子名字
/// </summary>
[Header("子名字")]
public string ChildName;

public ChildCustomClass(string name, string childName) : base(name)
{
ChildName = childName;
}
}

GrandchildCustomClass.cs(自定义孙子类)

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
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// GrandchildCustomClass.cs
/// 自定义孙子类
/// </summary>
[Serializable]
public class GrandchildCustomClass : ChildCustomClass
{
/// <summary>
/// 孙子名
/// </summary>
[Header("孙子名")]
public string GrandchildName;

/// <summary>
/// 孙子目标GameObject(测试UnityEngine.Object的自带序列化)
/// </summary>
[Header("孙子目标GameObject")]
public GameObject GrandchildTargetGo;

public GrandchildCustomClass(string name, string childName, string grandchildName, GameObject grandchildTargetGo) : base(name, childName)
{
GrandchildName = grandchildName;
GrandchildTargetGo = grandchildTargetGo;
}
}

SerializeReferenceMono.cs(序列化自定义类测试脚本)

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;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// SerializeReferenceMono.cs
/// 序列化自定义类测试脚本
/// </summary>
public class SerializeReferenceMono : MonoBehaviour
{
/// <summary>
/// 基类对象(测试多态)
/// </summary>
[Header("基类对象")]
[SerializeReference]
public BaseCustomClass BaseClass;

/// <summary>
/// 子类对象1(测试自定义Class序列化By Reference)
/// </summary>
[Header("子类对象1")]
[SerializeReference]
public ChildCustomClass ChildClass1;

/// <summary>
/// 子类对象2(测试自定义Class序列化By Reference)
/// </summary>
[Header("子类对象2")]
[SerializeReference]
public ChildCustomClass ChildClass2;

/// <summary>
/// 孙子类对象1(测试自定义Class序列化By Value)
/// </summary>
[Header("孙子类对象1")]
public GrandchildCustomClass GrandchildClass1;

/// <summary>
/// 孙子类对象2(测试自定义Class序列化By Value)
/// </summary>
[Header("孙子类对象2")]
public GrandchildCustomClass GrandchildClass2;

[ContextMenu("初始化")]
void Init()
{
Debug.Log("SerializeReferenceMono.Init()");
BaseClass = new GrandchildCustomClass("测试多态基名字", "测试多态子名字", "测试多态孙名字", this.gameObject);

var childClassInstance = new ChildCustomClass("测试自定义Class序列化By Reference基名字", "测试自定义Class序列化By Reference子名字");
ChildClass1 = childClassInstance;
ChildClass2 = childClassInstance;

var grandChildClassInstance = new GrandchildCustomClass("测试自定义Class序列化By Value基名字", "测试自定义Class序列化By Value子名字", "测试自定义Class序列化By Value孙名字", this.gameObject);
GrandchildClass1 = grandChildClassInstance;
GrandchildClass2 = grandChildClassInstance;
}

[ContextMenu("测试序列化")]
void TestSerialization()
{
Debug.Log("SerializeReferenceMono.TestSerialization()");
if(ChildClass1 == ChildClass2)
{
Debug.Log($"ChildClass1 == ChildClass2");
}
else
{
Debug.Log($"ChildClass1 != ChildClass2");
}

if (GrandchildClass1 == GrandchildClass2)
{
Debug.Log($"GrandchildClass1 == GrandchildClass2");
}
else
{
Debug.Log($"GrandchildClass1 != GrandchildClass2");
}
}

private void Awake()
{
Debug.Log($"SerializeReferenceMono.Awake()");
}

private void Start()
{
Debug.Log($"SerializeReferenceMono.Start()");
}
}

首先通过创建一个挂载了SerializeReferenceMono组件的GameObject,通过扩展的Inspector操作面板初始化数据:

SerializeReferenceDataInitOperation

初始化后我们会看到我们初始化后的数据:

SerializeReferenceMonoInspectorAfterInit

从上面可以看到我们成功初始化了我们想要的数据,从BaseClass成员已经可以看到标记了SerializeReference后我们成功实现了多重继承后的数据序列化。

接下来我们把初始化数据后的GameObject做成预制件,通过反向丢进场景实例化触发反序列化来测试自定义Class的SerializeReference相关的设计。

SerializeReferenceMonoGO

最后一步,实例化预制件后通过Inspector操作面板测试自定义Class的SerializeReference设计:

TestSerializeReferenceOperation

TestSerializeReferenceResult

从上面的打印可以看出,即使经历了Prefab的实例化(即SerializeReferenceMono脚本的数据反序列化),我们最初标记SerializeReference的ChildCustomClass序列化同一对象后反序列化依然是指向同一对象(By Reference),而没有标记SerializeReference的GrandchildCustomClass反序列化后指向的是不同对象(By Value),从而验证了Unity SerializeReference的By Reference序列化反序列化设计。

Note:

  1. Unity用By Value的方式序列化自定义Class更高效,但没法还原引用。

Unity序列化方案

Monobehaviour

说到数据存储,Unity里最典型的就是Monobehaviour,这个比较常用,只要符合前面提到的成员序列化要求即可。主要要注意的是自定义序列化Class的成员,看是否需要By Reference序列化

比较简单这里就不实战举例了。

ScriptableObject

ScriptableObject是Unity提供的一种可以序列化数据成本地Asset的一种方式

使用ScriptableObejct的两大场景如下:

  1. 想在Editor下存储一些数据
  2. 想在运行时序列化一些数据到本地

比较简单这里就不实战举例了。

Unity数据存储方案

EditorPrefs

Unity用于存储Editor本地数据的类(存储位置好像是注册表里)。

比较简单这里就不实战举例了。

Note:

  1. 运行时用PlayerPref

JsonUtility

Unity提供的Json工具类,用于Json的序列化和反序列化。

比较简单这里就不实战举例了。

其他

这里的其他是指其他常见的序列化方式:

  1. 二进制(e.g. Protobuf, FlagBuffer …….)
  2. XML

Editor常用标签

编译相关

  • InitializeOnLoadAttribute

    用于在Unity loads(个人理解是Unity打开或编译完成)后初始化一些Editor脚本

  • InitializeOnLoadMethodAttribute

    用于在Unity loads(个人理解是Unity打开或编译完成)后初始化一些Editor脚本),但在InitializeOnLoadAttribute之后执行

具体使用在博主编写的Asset可配置化管线后处理工具以及路点编辑工具有使用:

AssetPipeline

PathPointTool

Note:

  1. InitializeOnLoadMethodAttribute在InitializeOnLoadAttribute之后执行
  2. InitializeOnLoadAttribute在Asset导入完成前触发,不要在此流程里操作Asset相关东西,要处理Asset相关东西使用AssetPostprocessor.OnPostProcessAllAssets接口流程

打包流程相关

  • PostProcessBuildAttrbute

    打包后处理流程通知标签

Inspector相关

  • Header

    字段显示标题文本标签

  • HideInInspector

    不在Inpector显示标签

  • CustomPropertyDrawer

    自定义标签绘制

  • Range

    限定值范围标签

具体使用在博主编写的Unity Editor先关学习里使用:

UnityEditor知识

EditorUIStudy

序列化相关

  • Serializable

    标记可以序列化

  • SerializeField

    标记成员可以序列化

  • SerializeReference

    标记序列化方式为By Reference(按引用,C#里引用的概念,确保同一个对象序列化反序列化指向同一个对象)而非By Value(按值,类似C#里值类型的概念,只序列化值不保持引用关系,即反序列化后引用会丢失只有值会还原)

  • NonSerialized

    不参与序列化

待添加……

Editor相关

  • ExecuteInEditMode

    允许部分实例化脚本在不运行时执行一些声明周期方法(e.g. Awake,Update,OnGUI ……)

  • OnOpenAsset

    响应Asset双击打开

  • ExecuteAlways

    允许脚本在运行和非运行时总时运行(e.g. Update, OnGUI ……)

Editor操作扩展

窗口菜单

MenuItem用于扩展窗口菜单操作以及Asset右键操作等

比较简单这里就不实战举例了。

AddComponentMenu用于扩展添加组件菜单

比较简单这里就不实战举例了。

Inspector菜单

ContextMenu用于扩展指定脚本的…菜单操作栏

比较简单这里就不实战举例了。

ScriptableObject菜单

CreateAssetMenu用于创建继承至ScriptableObject的Asset菜单操作

比较简单这里就不实战举例了。

Editor工具分类

EditorUtility是Unity Editor里功能比较广泛的工具类,大部分接口跟Editor UI和Asset操作相关。

GUI相关

  • HandleUtility

    3D场景GUI绘制相关辅助工具类

待添加……

Asset相关

PrefabUtility

Unity里针对预制件操作相关的工具类

序列化相关

SerializationUtility

Unity序列化相关的工具类

Github

参考学习Github:

ThreeDDrawing

个人Github:

AssetPipeline

PathPointTool

EditorUIStudy

Reference

Unity 拓展编辑器入门指南

Script serialization

前言

本章节博客是在项目需要使用FGUI的前提下,驱动深入学习FGUI的使用的。关于FGUI个人的认知还停留在第三方跨平台跨引擎制作UI的工具的层面上,接下来还是遵循老规矩从What,Why,How三个步骤来一步一步深入学习FGUI。

FGUI

什么是FGUI了?

FGUI全称是FairyGUI。官方的介绍是:专业游戏 UI 解决方案从对设计师友好的编辑器 到支持 10+ 款引擎的 SDK 助力您生产力翻倍

为什么要选择FGUI了?

从官网的介绍可以看出,使用FGUI有以下几个好处:

  1. 跨平台,多引擎支持(意味着一套UI多处使用)
  2. 内部支持的多国语言
  3. 高性能DralCall合并优化机制
  4. 丰富的UI库(跨平台的UI组件库–省去了从零造UI轮子的功夫)

接下来就是学习如何使用FGUI了?

这里依然选择从官网教程入手:

编辑器使用基础

FGUI使用

第一步:

下载FGUI编辑器

第二步:

创建第一个FGUI项目(创建新项目->输入新项目名称以及选择位置)

FGUIProjectsCapture

FGUIProjectsStructure

编辑器的基础使用参考这里

项目设置参考这里

接下来学习下FGUI里总要的一些独有概念:

包的定义

FairyGUI是以包为单位组织资源的。包在文件系统中体现为一个目录。assets目录下每个子目录都表示一个包。**包内的每个资源都有一个是否导出的属性,一个包只能使用其他包设置为已导出的资源,而不设置为导出的资源是不可访问的。

包的依赖

FairyGUI是不处理包之间的依赖关系的,如果B包导出了一个元件B1,而A包的A1元件使用了元件B1,那么在创建A1之前,必须保证B包已经被载入,否则A1里的B1不能正确显示(但不会影响程序正常运行)。这个载入需要由开发者手动调用,FairyGUI不会自动载入。

资源URL地址

在FairyGUI中,每一个资源都有一个URL地址。选中一个资源,右键菜单,选择“复制URL”,就可以得到资源的URL地址。无论在编辑器中还是在代码里,都可以通过这个URL引用资源。

Note:

  1. URL有两种格式(一种是不可读的一个编码,另一种是ui://包名//资源名)

发布

FGUIPublicInterface

可以看到FGUI发布是针对前面设定的包的概念来发布的,可以发布一个包也可以发布所有包。发布里面还有很多设置,比如对纹理图片的导出设置等,具体后续用到详细研究详情参考:发布

这里主要说下发布代码这个设置,看介绍是一个FGUI提供的组件绑定代码自动化生成机制。

FGUI发布后会得到一个***_fui.bytes的二进制文件:

FGUIPublishAssets

Note:

  1. 当我们载入包时,需要使用这里设定的文件名,而当创建对象时,需要使用包名称

元件

每个舞台中的组成元素我们称之为元件

元件的类型有很多,他们是:

  1. 基础元件(图片,图形,动画,装载器,文本,富文本,组,组件)
  2. 组合型元件(标签,按钮,下拉框,滚动条,滑动条,进度条)
  3. 特殊元件(列表)

Note:

  1. 关联系统只对元件的宽高有效,不计入Scale的影响
  2. 设置元件的倾斜值。 对于Unity平台,你可以放心地对图片、动画、装载器使用倾斜,这几乎不会带来额外消耗,但对于其他类型的元件,例如组件,请谨慎使用
  3. BlendMode这个提供了一部分的混合选项设置。对于Unity平台,对图片、动画、文字,你可以放心地修改它们的BlendMode。但对于组件,请谨慎使用。滤镜设置同理。

GObject

FGUI里大部分元件类的基类。个人理解是元件的根Object抽象。

关键元件的位置,缩放,旋转,可见,大小等设置接口都在这个类里。

GComponent

与Unity里的Component组件不同。个人理解更像是一种包含关系的Component而非GameObject那种绑定Component概念。

图片

可以理解成Unity里导入图片资源,然后可以对图片资源的大小,是否切九宫,纹理集导出设置等进行设置。

设置图片资源为导出后,发布包会看到生成了两份数据:

PictureResourceExport

一份为图片资源导出后的图集资源

一份为包的二进制资源

GImage是图片对应FGUI里的类型,类似Unity的Image,相关设置接口都在GImage类里。

Note:

  1. FairyGUI支持的图片格式有:PNG,JPG,TGA,SVG。
  2. 默认导入的图片是没有标记小红点的也就是默认不导出的,这里需要选中后右键设置为导出,最后导出包的时候才会导出资源。(只有导出后的资源才允许被其他包使用)

动画

FGUI支持三种方式创建动画,详情参考:动画

这里主要学习直接在FGUI里创建动画的方式:

  1. 资源-> 新建动画
  2. 导入序列帧图片资源
  3. 设置动画属性参数

动画文件对应FGUI里的GMoviewClip类,相关API参考GMoviewClip源码

骨骼动画

暂时略过

图形

FairyGUI支持生成简单的图形。

多边形图形编辑支持直接操作顶点。

图形的主要用途是用于占位,支持设置绑定原生对象(e.g. Image)

图形对应FGUI里的GGraph类,相关API参考GGraph源码

Note:

  1. 图形支持动态创建,动态创建图形需要注意一定要设置图形的大小,否则显示不出来。

装载器

装载器的用途是动态载入资源。

装载器对应FGUI里的GLoader类,相关API参考GLoader源码。

GLoader可以载入图片、动画和组件。如果是UI包里的资源,那么通过“ui://包名/图片名”这种格式的地址就可以载入。

FGUI支持了我们自定义装载器实现自定义加载,详情参考官网:装载器

Note:

  1. 默认的GLoader具有有限度的的加载外部资源的能力,详情参考参考官网说明
  2. GLoader不管理外部对象的生命周期,不会主动销毁自定义加载的外部资源需要自行管理

3D内容装载器

3D内容装载器的用途是动态载入比较复杂的资源,例如骨骼动画、模型(暂未支持)、粒子特效(暂未支持)等。

暂时略过

文本

文本是FairyGUI的基础控件之一。文本不支持交互,鼠标/触摸感应是关闭的。

文本对应FGUI里的GTextField类,相关API参考GTextField源码。

使用文本模板可以更灵活的动态调整文本输出。解决文本占位输出问题。比如我的元宝:{jin=100}金{yin=200}银。勾选文本模板即可通过直接更新关键字数值即可。

事例:

1
aTextField.SetVar("jin", "500").SetVar("yin", "500").FlushVars();

Note:

  1. 设置文本支持UBB语法。使用UBB语法可以使单个文本包含多种样式,例如字体大小,颜色等。请参考UBB语法
  2. 文本模板优先于UBB解析

富文本

富文本与普通文本的区别在于:

  1. 普通文本不支持交互,鼠标/触摸感应是关闭的;富文本支持。
  2. 普通文本不支持链接和图文混排;富文本支持。
  3. 普通文本不支持HTML语法(但可以使用UBB实现不同样式);富文本支持。

富文本对应FGUI里的GRichTextField类,相关API参考GRichTextField源码。

输入文本

输入文本元件用于接收用户输入文字。

输入文本对应FGUI里的GTextInput类,相关API参考GTextInput源码。

Note:

  1. 设置文本支持UBB语法。

字体

FairyGUI支持3种字体的使用方式:

  1. 系统字体
  2. TTF字体
  3. 位图字体

Note:

  1. 无论字体是否设置为导出,它都不会被发布。UI包发布后,引用此字体资源的文本元件的字体名称是该字体的资源名字。
  2. FairyGUI内置支持使用TextMeshPro插件。

在舞台上选定一个或多个元件,然后按Ctrl+G,就可以建立一个组。 FairyGUI的组有两种类型,普通组高级组

个人觉得FGUI里的组类似Unity里的父节点,同时高级组实现了一些类似Layout排版组件的布局效果以及其他特殊控制效果。

高级组对应FGUI里的GGroup类,相关API参考GGroup类源码。

组件

组件是FairyGUI中的一个基础容器。组件可以包含一个或多个基础显示对象,也可以包含组件。

设计图功能是为了快速拼出效果图要的效果。

点击穿透设置可以快速将点击事件向后传递。

点击测试用于不规则区域的点击响应。

遮罩FGUI里分两种:

  • 矩形遮罩

  • 自定义遮罩

扩展功能,选择哪种“扩展”,组件就有了那种扩展的属性和行为特性。

组件对应FGUI里的GComponent类,相关API详情参考GComponent源码。

动态创建的组件是空组件,可以作为其他组件的容器。一个常见的用途,如果你要建立一个多层的UI管理系统,那么空组件就是一个合适的层级容器选择。

Note:

  1. 动态创建的组件默认是点击穿透的,也就是说如果直接new一个空组件作为接收点击用途,你还得有额外设置
  2. 在FairyGUI中,显示列表是以树形组织的,下面说的渲染顺序均指在同一个父元件下安排的顺序,不同父元件的元件是不可能互相交错的,这是前提,请注意。
  3. GObject.sortingOrder。**这个属性只用于特定的用途,不作常规的使用。它一般用于类似固定置顶的功能,另外,永远不要将sortingOrder用在列表中。

滚动容器

滚动容器对应FGUI里的ScrollPane类,相关API详情参考ScrollPane源码。

……

控制器

控制器是FairyGUI核心功能之一,它为UI制作中以下类似需求提供了支持:

  • 分页 一个组件可以由多个页面组成。
  • 按钮状态 按钮通常有按下、鼠标悬浮等多个状态,我们可以利用控制器为每个状态安排不同的显示内容。
  • 属性变化 利用控制器,我们可以使元件具有多个不同的形态,并且可以方便地切换。

这里提到了一个重要的概念:分页。

控制器的概念是基于分页的,意思就是通过设计控制的页面的控制效果,我们可以提前做好多个页面效果,运行时只需切页来达到对应效果即可。

显示控制-2一般和显示控制搭配使用,它可以实现两个控制器控制一个元件显隐的需求。特别还提供了一个逻辑关系的选项,可以选择“与”或者“或”。

控制器可以很方便实现类似Button多种点击状态以及Toggle选中和未选中效果(程序只需负责切页即可,所有显示效果在FGUI编辑器里定义好)。

比如单选按钮就是通过多个单选按钮配合多个页面控制+按钮联动实现的。

这里的控制器相当于把操作表现流程放到UI制作流程里了(不得不说FGUI确实对于UI制作流有很大好处,部分逻辑都不再需要程序自行编写了)

滚动容器对应FGUI里的Controller类,相关API详情参考Controller源码。

关联系统

关联系统是FairyGUI实现自动布局的核心技术。其他UI框架提供的布局系统,一般只提供各种固定的layout,或者锚点,都只能定义元件与容器之间的关系。而FairyGUI的关联能够定义任意两个元件的关系,而且互动方式更多样。

FGUI的关联系统,个人理解更偏向于美术的概念,而非传统程序锚点的概念,左左,左右,右左这些都是显示知名两个Component的位置关系。

标签

标签对应FGUI里的GLabel类,相关API详情参考GLabel源码。

Note:

  1. 标签里title和icon是两个特殊的名字,FGUI设置标签文本和图表会快速查找对应名字作为对应组件。

按钮

标签对应FGUI里的GButton类,相关API详情参考GButton源码。

……

下拉框

下拉框对应FGUI里的GComboBox类,相关API详情参考GComboBox源码。

……

进度条

进度条对应FGUI里的GProgressBat类,相关API详情参考GProgressBar源码。

……

滑动条

滑动条对应FGUI里的GSlider类,相关API详情参考GSilder源码。

……

滚动条

与很多UI框架使用皮肤机制定义滚动条不同,在FairyGUI中,滚动条是可以随心设计的。
滚动容器和滚动条是独立的,也就是说,即使没有滚动条,滚动容器也能完成滚动的功能。
注意:滚动条不能直接拖到舞台上使用,永远不要这样做。

滚动条对应FGUI里的GScrollBar类,相关API详情参考GScrollBar源码。

Note:

  1. 运行时滚动条组件的类型是GScrollBar,但你不需要访问GScrollBar对象。所有滚动相关的操作都通过ScrollPane完成

列表

滚动条对应FGUI里的GList类,相关API详情参考GList源码。

这是我们游戏中会比较常用的组件,FGUI的列表已经实现了数据与渲染分离(虚拟列表),也实现了列表所需的几乎所有需求(e.g. 动态大小,动态魔板,循环列表,滚动到指定单元格等)

详情参考:FGUI列表

Note:

  1. 不允许使用AddChild或RemoveChild对虚拟列表增删对象。如果要清空列表,必须要通过设置numItems=0,而不是RemoveChildren。

FGUI设计

显示UI面板

待续……

Reference

FGUI

Introduction

游戏开发过程中,特别是战斗中避免不了要判定各种攻击和技能是否击中,而这些判定都得通过碰撞系统来实现正确的判定。本篇文章正是为了学习了解战斗系统中攻击技能判定是如何实现的而编写的。

后续大部分理论知识和小部分文字以及截图来源:

【Unity】图形相交检测

感谢该博主的无私分享

所有的代码后续会传到Github上,想查看可视化绘制的完整代码可在Github上下到。

碰撞检测

What

碰撞检测 – 碰撞检测指判定物体(不论是3D还是2D物体)之间的相交。比如物理系统里碰撞检测指的是物体之间的实体碰撞以及物理效果。战斗系统里碰撞检测指的是技能和攻击的命中判定。

Why

那么为什么需要碰撞检测了?

  1. 物理游戏里要模拟真实的物理效果,碰撞检测是必不可少的。
  2. 战斗系统里,攻击和技能的命中判定都离不开碰撞检测。
  3. 纯数学级别的碰撞检测模拟比Unity自带的Collider准确且高效。

Note:

  1. 虽然我们可以通过Unity现成的Collider去做碰撞检测,但Collider的碰撞判定顺序是不可控的,并且Collider组件会带来很大的性能开销,所以无论是出于准确性还是性能考虑,战斗力的技能和攻击判定我们都不能直接用Collider(特别是涉及网络同步的时候)。

How

功能需求

  1. 支持3D一些简单形状(点,AABB,OBB,球形)的碰撞检测判定
  2. 支持2D一些简单形状(点,矩形,圆形,扇形,胶囊体,OBB)的碰撞检测判定

理论知识

碰撞检测从原理上来说就是数学运算,就是图形相交检测。

所以要实现一套自己的碰撞检测系统,我们要先学习图形相交检测相关的数学知识。

这里我们以2D的碰撞检测为例来学习实现碰撞检测系统。复杂的3D碰撞检测很多时候可以简化到2D来实现相同的效果并且得到更高效的判定结果。后续只会实现一部分简单的3D形状的碰撞判定。

2D形状

点在2D里我们通过X,Y坐标来定义,所以点的定义如下:

1
public Vector2 Point;
圆形

圆形是由圆心+半径来定义,圆形的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/// <summary>
/// 2D圆形定义
/// </summary>
public struct Circle2D
{
/// <summary>
/// 中心点位置
/// </summary>
public Vector2 Center;

/// <summary>
/// 半径
/// </summary>
public float Radius;

public Circle2D(Vector2 center, float radius)
{
Center = center;
Radius = radius;
}
}
矩形(轴对齐包围盒-AABB)

矩形(轴对齐包围盒-AABB)是由中心点+长宽来定义,长宽边分别与X/Y轴平行的矩形,矩形(轴对齐包围盒-AABB)定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/// <summary>
/// 2D AABB定义
/// </summary>
public struct AABB2D
{
/// <summary>
/// 中心点位置
/// </summary>
public Vector2 Center;

/// <summary>
/// 宽和高
/// </summary>
public Vector2 Extents;

public AABB2D(Vector2 center, Vector2 extents)
{
Center = center;
Extents = extents;
}
}

Note:

  1. 矩形(轴对齐包围盒-AABB)的长宽平行于X和Y轴
有向包围盒(OBB)

有向包围盒(OBB)可以理解成一个带旋转角度的矩形(轴对齐包围盒-AABB),正因为矩形(轴对齐包围盒-AABB)无法表达长宽不平行与X和Y轴的形状,所以才有了有向包围盒(OBB)。

让我们通过下图来形象的理解圆形,矩形(轴对齐包围盒-AABB)和有向包围盒(OBB)的区别:

CIRCLE_AABB_OBB_DES

向包围盒(OBB)由中心点+长宽+旋转角度来定义,定义如下:

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
/// <summary>
/// 2D OBB定义
/// </summary>
public struct OBB2D
{
/// <summary>
/// 中心点位置
/// </summary>
public Vector2 Center;

/// <summary>
/// 宽和高
/// </summary>
public Vector2 Extents;

/// <summary>
/// 旋转角度
/// </summary>
public float Angle;

public OBB2D(Vector2 center, Vector2 extents, float angle)
{
Center = center;
Extents = extents;
Angle = angle;
}
}
胶囊体

胶囊体是由起点+线段u+胶囊体半径d定义。胶囊体实际上是与线段u的最短距离d的点的集合。

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
/// <summary>
/// 2D胶囊体定义
/// </summary>
public struct Capsule2D
{
/// <summary>
/// 起点
/// </summary>
public Vector2 StartPoint;

/// <summary>
/// 起点线段
/// </summary>
public Vector2 PointLine;

/// <summary>
/// 胶囊体半径
/// </summary>
public float Radius;

public Capsule2D(Vector2 startpoint, Vector2 pointline, float radius)
{
StartPoint = startpoint;
PointLine = pointline;
Radius = radius;
}
}

SphereDefinition

从上面引申出了点与线段的最短距离算法:

1
2
3
4
5
6
7
8
9
10
11
12
/// <summary>
/// 点与线段的最短距离
/// </summary>
/// <param name="startpoint">线段起点</param>
/// <param name="line">线段向量</param>
/// <param name="targetpoint">求解点</param>
/// <returns></returns>
public static float SqrDistanceBetweenSegmentAndPoint(Vector2 startpoint, Vector2 line, Vector2 targetpoint)
{
float t = Vector2.Dot(targetpoint - startpoint, line) / line.sqrMagnitude;
return (targetpoint - (startpoint + Mathf.Clamp01(t) * line)).sqrMagnitude;
}

要理解上面的公式,需要知道向量里的一些基本概念,参考:

向量学习

上面的计算公式理解如下:

float t = Vector2.Dot(targetpoint - startpoint, line) / line.sqrMagnitude; // 计算求解点映射到线段line上的比例。

(startpoint + Mathf.Clamp01(t) * line) // 计算的是映射点P点的坐标

(targetpoint - (startpoint + Mathf.Clamp01(t) * line) ) // 表达的是目标点到线段的那条向量d

(targetpoint - (startpoint + Mathf.Clamp01(t) * line) ) .sqrMagnitude; // 得到目标点到线段的向量的距离平方(后续用平方比代替开方比,因为平方比在计算机上比开方比更快)

后续计算形状相交很多都会转化成点与线的距离来比较实现碰撞检测

Note:

  1. 判定一个点处于胶囊体内部,就是判断点与线段的距离
  2. 计算机开方计算比平方计算慢,所以我们采用平方比较来代替开方值比较
扇形

扇形是由起点+扇形半径+扇形朝向+扇形角度定义,扇形定义如下:

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
/// <summary>
/// 2D扇形定义
/// </summary>
public struct Sector2D
{
/// <summary>
/// 扇形起点
/// </summary>
public Vector2 StartPoint;

/// <summary>
/// 扇形半径
/// </summary>
public float Radius;

/// <summary>
/// 扇形朝向
/// </summary>
public Vector2 Direction;

/// <summary>
/// 扇形角度
/// </summary>
public float Angle;

public Sector2D(Vector2 startpoint, float radius, Vector2 direction, float angle)
{
StartPoint = startpoint;
Radius = radius;
Direction = direction;
Angle = angle;
}
}
凸多边形

多边形的定义是否多个顶点位置定义的,多边形定义如下:

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
/*
* Description: Polygon2D.cs
* Author: TONYTANG
* Create Date: 2022/03/20
*/

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

namespace TH.Module.Collision2D
{
/// <summary>
/// Polygon2D.cs
/// 多边形定义
/// </summary>
public struct Polygon2D
{
/// <summary>
/// 多边形顶点(注意保持统一顺时针或者逆时针定义)
/// </summary>
public Vector2[] Vertexes;

public Polygon2D(Vector2[] vertexes)
{
Vertexes = vertexes;
}

/// <summary>
/// 是否是有效多边形
/// </summary>
/// <returns></returns>
public bool IsValide()
{
if (Vertexes == null || Vertexes.Length < 3))
{
Debug.LogError("多边形顶点数不应该小于3个!");
return false;
}
return true;
}
}
}

Note:

  1. 上述定义并不能保证一定是凸多边形

分离轴定理

了解了各种形状的定义后,在了解真正的碰撞检测判定前,为了更好的理解后面的碰撞检测计算,我们先来了解一个概念分离轴定理

分离轴定理(separating axis theorem, SAT)分离轴定理是指,两个不相交的凸集必然存在一个分离轴,使两个凸集在该轴上的投影是分离的。

判断两个形状是否相交,实际上是判断分离轴是否能把两个形状分离。若存在分离轴能使两个图形分离,则这两个图形是分离的。

基于以上理论,寻找分离轴是我们要做的工作,重新考虑两个圆形的相交检测,实际上我们做的是把圆心连线的方向作为分离轴:

SAT

通过分离轴定理的定义可以看出,通过找到两个凸集形状的分离轴,我们可以实现两个形状相交的判定,这个会成为我们后面判定碰撞检测的重要理论知识

接下来让我们真正进入碰撞检测实战。

形状碰撞检测

点与圆形

点与圆形的相交判定实际上就是判定点与圆心的距离是否大于半径。

这里理论很简单,就不上效果图了,直接看代码:

1
2
3
4
5
6
7
8
9
10
/// <summary>
/// 判断圆形与点之间的交叉检测
/// </summary>
/// <param name="circle1"></param>
/// <param name="point"></param>
/// <returns></returns>
public static bool CircleAndPointIntersection2D(Circle2D circle1, Vector2 point)
{
return (circle1.Center - point).sqrMagnitude < circle1.Radius * circle1.Radius;
}

点与矩形(AABB)

点与矩形的判定也比较容易,利用AABB的轴和XZ平行,我们可以直接比较点的X和Z是否在AABB的最大最小范围内即可(也可以理解成点与AABB中心的距离是否小于等于AABB的宽/2和长/2)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/// <summary>
/// 点是否在矩形(AABB)内
/// </summary>
/// <param name="aabb"></param>
/// <param name="point"></param>
/// <returns></returns>
public static bool PointInAABB2D(AABB2D aabb, Vector2 point)
{
// 点和AABB原点的位置偏移(等价于将点移动到AABB坐标系)
Vector2 offset = aabb.Center - point;
offset = Vector2.Max(offset, -offset);
// 判定偏移是否小于AABB宽/2和AABB长/2
return offset.x <= aabb.Extents.x / 2 && offset.y <= aabb.Extents.y / 2;
}

PointIntersectionWithAABB2D

代码比较简单就不详细解释了。

点与多边形

判定一个点是否在多边形内,本文使用的是角度和和**叉积(点线)**判定法,更多的解题思路参考:

详谈判断点在多边形内的七种方法(最全面)

上文中提到射线法比较优,但考虑到理解难易度,还是角度和和**叉积(点线)**法更易理解,本文主要以这两种解法为例。

角度和法

学过平面几何的同学都知道,如果一个点在凸多边形内,那么这个点和凸多边形各顶点连线构成的夹角和应该为360度

角度和法正是利用了这一个定义来实现一个点是否在凸多边形内的。

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
/// <summary>
/// 点是否在多边形内(内角和法)
/// </summary>
/// <param name="polygon">多边形</param>
/// <param name="point"></param>
/// <returns></returns>
public static bool PointInPolygon2D(Polygon2D polygon, Vector2 point)
{
if(!polygon.IsValide())
{
return false;
}
Vector2[] polygonVertexes = polygon.Vertexes;
int polygonVertexsNumber = polygonVertexes.Length;
// 点与所有多边形顶点的连线向量
Vector2[] pointLines = new Vector2[polygonVertexsNumber];
for(int i = 0, length = polygonVertexsNumber; i < length; i++)
{
pointLines[i] = polygonVertexes[i] - point;
}
// 计算所有多边形顶点连线之间的夹角总和
float totalAngle = Vector2.SignedAngle(pointLines[polygonVertexsNumber - 1], pointLines[0]);
for(int i = 0, length = polygonVertexsNumber - 1; i < length; i++)
{
totalAngle += Vector2.SignedAngle(pointLines[i], pointLines[i + 1]);
}
if (Mathf.Abs(Mathf.Abs(totalAngle) - 360f) < 0.1f)
{
return true;
}
return false;
}

PolygonVertexesInfo

PointOutOfPolygon

PointInPolygon

从上面可以看到我们通过累加点到多边形所有顶点构成的线段的夹角总和判定除了点是否在多边形内。但上面的方法new了大量的Vector3数组以及用到了Vector2.SignedAngle()等反三角函数,所以速度和效率上并不是不太优,接下来我们会讲到针对点是否在凸多边形内的更优解法(叉乘(点线)法)。

Note:

  1. 此方法适用于凸多边形和凹多边形
叉积(点线)法

叉积(点线)法的核心思想是判定点到多边形所有顶点构成的边是否都在相邻多边形边的一侧(顺时针的话需要都在左侧,逆时针的话需要都在右侧)

这里我们利用叉乘(叉乘结果>0还是<0能区分)来实现两个向量的左右关系判定。

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
/// <summary>
/// 点是否在凸边形内(叉积(点线)法)
/// Note:
/// 1. 确保是凸多边形
/// 2. 确保凸多边形顶点是顺时针
/// </summary>
/// <param name="polygon">凸多边形</param>
/// <param name="point"></param>
/// <returns></returns>
public static bool PointInConvexPolygon2D(Polygon2D polygon, Vector2 point)
{
if (!polygon.IsValide())
{
return false;
}
Vector2[] polygonVertexes = polygon.Vertexes;
int polygonVertexsNumber = polygonVertexes.Length;
Vector3 pointLine;
Vector3 polygonEdge;
int index;
// 这里采用顶点顺时针判定
for (int i = 0, length = polygonVertexsNumber; i < length; i++)
{
pointLine = new Vector3(point.x - polygonVertexes[i].x, 0, point.y - polygonVertexes[i].y);
index = (i + 1) % polygonVertexsNumber;
polygonEdge = new Vector3(polygonVertexes[index].x - polygonVertexes[i].x, 0, polygonVertexes[index].y - polygonVertexes[i].y);
// 计算多边形顶点的连线向量和边的左右关系
// Vector3.Cross(A, B).y > 0 表示A在B的左侧
// Vector3.Cross(A, B).y < 0 表示A在B的右侧
// Vector3.Cross(A, B).y == 0 表示A和B平行
if (Vector3.Cross(pointLine, polygonEdge).y >= 0)
{
return false;
}
}
return true;
}

PolygonVertexesInfo2

PointOutOfPolygon2

PointInPolygon2

从上面的效果图可以看出我们通过叉积法也成功判定出了点是否在凸多边形内。

但上面的方式需要创建大量的Vector3来构建边以及叉乘判定,虽然Vector3是结构体类型不会造成GC问题,但核心只是为了实现两个向量的方向判定,依然有优化的空间。

我们可以把2D的向量方向当做就在XZ平面的3D向量来计算(即把Y轴值当做0),通过叉乘最原始的公式直接计算叉乘后Y轴值的大小来判定两个向量的方向关系

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
/// <summary>
/// 点是否在凸边形内(叉积(点线)法--不构建Vector3纯数学运算)
/// Note:
/// 1. 确保是凸多边形
/// 2. 确保凸多边形顶点是顺时针
/// </summary>
/// <param name="polygon">凸多边形</param>
/// <param name="point"></param>
/// <returns></returns>
public static bool BetterPointInConvexPolygon2D(Polygon2D polygon, Vector2 point)
{
if (!polygon.IsValide())
{
return false;
}
Vector2[] polygonVertexes = polygon.Vertexes;
int polygonVertexsNumber = polygonVertexes.Length;
int index;
float lineX;
float lineZ;
float edgeX;
float edgeZ;
// 把2D向量当做XZ平面的3D向量来处理
// 不构建Vector3的边,直接利用叉乘公式计算叉乘后的Y值
// 然后通过Y值来判定两个2D向量的方向
// 这里采用顶点顺时针判定
for (int i = 0, length = polygonVertexsNumber; i < length; i++)
{
lineX = point.x - polygonVertexes[i].x;
lineZ = point.y - polygonVertexes[i].y;
index = (i + 1) % polygonVertexsNumber;
edgeX = polygonVertexes[index].x - polygonVertexes[i].x;
edgeZ = polygonVertexes[index].y - polygonVertexes[i].y;
// 利用叉乘原始公式进行计算判定两个向量的方向
// Vector3.Cross(A, B).y > 0 表示A在B的左侧
// Vector3.Cross(A, B).y < 0 表示A在B的右侧
// Vector3.Cross(A, B).y == 0 表示A和B平行
if((lineZ * edgeX - lineX * edgeZ) > 0)
{
return false;
}
}
return true;
}

PolygonVertexesInfo3

PointOutOfPolygon3

PointInPolygon3

可以看到不通过构建Vector3,我们直接利用叉乘的公式把2D当3D计算出叉乘的Y值,依然可以有效的判定出点是否在凸多边形内。

在3D维度两个向量不再是在单纯的XZ平面而是在任意平面,这里不再能直接使用叉乘的y值进行判定,除非两个向量在XZ平面。

Note:

  1. 此方法适用于凸多边形
  2. 注意叉积判定对于多边形顶点的顺序要求
  3. 2维向量叉乘AXB = (0, 0, x1 * y2 - x2 * y1),x1 * y2 - x2 * y1大于0表示A在B的右侧,小于0表示A在B的左侧,等于0表示A和B平行(大前提是左手坐标系,右手坐标系左右侧是反过来的)
  4. 叉乘y大于0表示A在B的左侧还是小于0表示A在B的左侧主要看我们用的左手坐标系还是右手坐标系,因为Unity是左手坐标系,所欲叉乘y大于0表示A在B的左侧
  5. 利用叉乘结果y大于0还是y小于0区分方向的同时,我们还能用于推断向量A在向量B的左侧还是右侧(大前提是两个向量在XZ平面)

圆形与圆形

圆形和圆形相交判定起始就是2个圆心的距离是否大于两个圆的半径之和。

1
2
3
4
5
6
7
8
9
10
/// <summary>
/// 判断圆形与圆形之间的交叉检测
/// </summary>
/// <param name="circle1"></param>
/// <param name="circle2"></param>
/// <returns></returns>
public static bool CircleAndCircleIntersection2D(Circle2D circle1, Circle2D circle2)
{
return (circle1.Center - circle2.Center).sqrMagnitude < (circle1.Radius + circle2.Radius) * (circle1.Radius + circle2.Radius);
}

这个比较简单没什么好说的。

圆形与胶囊体

结合前面分离轴定理,我们要想判定圆形和胶囊体是否相交,首先找到分离轴,而圆形和胶囊体的分离轴是胶囊体线段上距离圆形最近的点P与圆心所在的方向。

所以判定圆心到胶囊体线段的距离是否大于胶囊体半径+圆形半径即可。

1
2
3
4
5
6
7
8
9
10
11
/// <summary>
/// 判断圆形与胶囊体相交
/// </summary>
/// <param name="circle"></param>
/// <param name="capsule"></param>
/// <returns></returns>
public static bool CircleAndCapsuleIntersection2D(Circle2D circle, Capsule2D capsule)
{
float sqrdistance = SqrDistanceBetweenSegmentAndPoint(capsule.StartPoint, capsule.PointLine, circle.Center);
return sqrdistance < (circle.Radius + capsule.Radius) * (circle.Radius + capsule.Radius);
}

Circle2DIntersectionWithCapsule2D

Capsule

Circle2DIntersectionWithRotateCapsule2D

Capsule2

圆形与矩形(AABB)

利用AABB的对称性,我们以AABB中心为原点,两边为坐标轴的坐标系。通过将圆形移动AABB的大小,然后看圆形所在位置与中心点距离是否还大于圆形半径来判定圆形和AABB是否相交。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/// <summary>
/// 判断圆形与AABB相交
/// </summary>
/// <param name="circle"></param>
/// <param name="aabb"></param>
/// <returns></returns>
public static bool CircleAndAABBIntersection2D(Circle2D circle, AABB2D aabb)
{
// 以AABB中心为圆心
Vector2 v = Vector2.Max(circle.Center - aabb.Center, -(circle.Center - aabb.Center));
// 把圆心坐标偏移AABB大小
Vector2 u = Vector2.Max(v - aabb.Extents / 2, Vector2.zero);
// 判定圆心距离是否还大于圆形半径判定相交
return u.sqrMagnitude < circle.Radius * circle.Radius;
}

Circle2DIntersectionWithAABB

AABB2DInfo

圆形与有向包围盒(OBB)

结合前面OBB的定义:

有向包围盒(OBB)可以理解成一个带旋转角度的矩形(轴对齐包围盒-AABB)

可以看出OBB和AABB的主要区别是支持了选择的AABB,那么我们判定OBB和圆形的相交检测也就等价于把圆形旋转到OBB所在坐标系后,然后当做圆形和AABB相交判定即可。

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
/// <summary>
/// Vector3临时变量(用于优化new Vector3)
/// </summary>
private static Vector3 Vector3Temp;

/// <summary>
/// 判定圆形和OBB相交检测
/// </summary>
/// <param name="circle"></param>
/// <param name="obb"></param>
/// <returns></returns>
public static bool CircleAndOBBIntersection2D(Circle2D circle, OBB2D obb)
{
// 以OBB中心为坐标原点
Vector2 point = circle.Center - obb.Center;
Vector3Temp.x = point.x;
Vector3Temp.y = 0;
Vector3Temp.z = point.y;
// 将圆心通过反向旋转转到OBB坐标系
Vector3 point2 = Quaternion.AngleAxis(-obb.Angle, Vector3.up) * Vector3Temp;
point.x = point2.x;
point.y = point2.z;
// 将旋转后的圆心位置和OBB做AABB方式的相交判定
Vector2 v = Vector2.Max(point, -point);
// 把圆心坐标偏移OBB大小
Vector2 u = Vector2.Max(v - obb.Extents / 2, Vector2.zero);
// 判定圆心距离是否还大于圆形半径判定相交
return u.sqrMagnitude < circle.Radius * circle.Radius;
}

Circle2DIntersectionWithOBB2D

OBB2DInfo

Note:

  1. Quaternion.AngleAxis()如果绕Y轴旋转直接乘以Vector2会得到错误的结果,所以我们需要当做Vector3来计算,最后再转换回Vector2
  2. 虽然Vector3是结构体不会造成GC,但不想每次判定都创建Vector3所以定义了一个临时Vector3用于计算重用

圆形与凸多边形

圆形与凸多边形相交判定只需要在点和凸多边形相交判定上增加圆心不在凸多边形内时,判定圆心到多边形每条边的距离是否有小于圆形半径即可。

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
/// <summary>
/// 判断圆形与多边形相交
/// </summary>
/// <param name="circle"></param>
/// <param name="polygon"></param>
/// <returns></returns>
public static bool CircleAndPolygonIntersection2D(Circle2D circle, Polygon2D polygon)
{
// 判定圆心是否在多边形内
if(BetterPointInConvexPolygon2D(polygon, circle.Center))
{
return true;
}
// 圆心不在多边形内,则判定圆心与每条边的距离是否小于半径
Vector2 circleCenter = circle.Center;
float sqrR = circle.Radius * circle.Radius;
var vertexes = polygon.Vertexes;
int polygonVertexsNumber = polygon.Vertexes.Length;
Vector2 edge;
int index;
for (int i = 0, length = polygonVertexsNumber; i < length; i++)
{
index = (i + 1) % polygonVertexsNumber;
edge.x = vertexes[index].x - vertexes[i].x;
edge.y = vertexes[index].y - vertexes[i].y;
// 判定圆心到单条边的距离是否小于半径
if(SqrDistanceBetweenSegmentAndPoint(vertexes[i], edge, circleCenter) < sqrR)
{
return true;
}
}
return false;
}

Circle2DIntersectionPolygon2D

注释写的很详细了,就不详细解释说明了。

圆形与扇形

当扇形角度大于180度时,就不再是凸多边形了,不能适用于分离轴理论。

我们把相交判定分成两个部分判定:

  1. 圆形在扇形角度内,直接判定扇形原点和圆心距离是否大于扇形半径+圆形半径
  2. 圆形在扇形角度外,利用扇形对称性,将原因映射到扇形一侧,通过映射后的原因与扇形一条边的距离是否小于圆形半径判定相交
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
/// <summary>
/// 判断圆形与扇形相交
/// </summary>
/// <param name="circle"></param>
/// <param name="sector"></param>
/// <returns></returns>
public static bool CircleAndSectorIntersection2D(Circle2D circle, Sector2D sector)
{
Vector2 tempDistance = circle.Center - sector.StartPoint;
float halfAngle = Mathf.Deg2Rad * sector.Angle / 2;
// 判定扇形起点和圆心距离是否小于扇形半径+圆形半径
if (tempDistance.sqrMagnitude < (sector.Radius + circle.Radius) * (sector.Radius + circle.Radius))
{
// 判定圆形是否在扇形角度内
if (Vector3.Angle(tempDistance, sector.Direction) < sector.Angle / 2)
{
return true;
}
else
{
// 利用扇形的对称性,将圆心位置映射到一侧
Vector2 targetInsectorAxis = new Vector2(Vector2.Dot(tempDistance, sector.Direction), Mathf.Abs(Vector2.Dot(tempDistance, new Vector2(-sector.Direction.y, sector.Direction.x))));
// 扇形的一条边
Vector2 directionInSectorAxis = sector.Radius * new Vector2(Mathf.Cos(halfAngle), Mathf.Sin(halfAngle));
// 通过判定映射后的原因与扇形一条边的距离是否小于圆形半径判定相交
return SqrDistanceBetweenSegmentAndPoint(Vector2.zero, directionInSectorAxis, targetInsectorAxis) <= circle.Radius * circle.Radius;
}
}
return false;
}

Circle2DIntersectionWithSector2D

Sector2DInfo

Note:

  1. 当扇形角度大于180度时,就不再是凸多边形了,不能适用于分离轴理论。

矩形(AABB)与矩形(AABB)

AABB与AABB的相交判定,考虑到AABB的轴都相同且都为矩形的特殊性,相交判定只需要判定两个AABB原点的距离是否有任何一个小于宽/2之和或长/2之和即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/// <summary>
/// 判定AABB和AABB相交检测
/// </summary>
/// <param name="aabb1"></param>
/// <param name="aabb2"></param>
/// <returns></returns>
public static bool AABBAndAABBIntersection2D(AABB2D aabb1, AABB2D aabb2)
{
// 两个AABB原点的位置偏移
Vector2 offset = aabb2.Center - aabb1.Center;
offset = Vector2.Max(offset, -offset);
// 判定偏移是否小于两者宽/2之和和两者长/2之和
return offset.x <= (aabb1.Extents.x / 2 + aabb2.Extents.x / 2) && offset.y <= (aabb1.Extents.y / 2 + aabb2.Extents.y / 2);
}

AABB2DIntersectionWithAABB2D

AABB2DInfo1

AABB2DInfo2

AABB和AABB的判定比较简单,就不详细解释了。

进阶学习

关于OBB等凸多边形相关的交叉判定,我们需要用到分离轴定理,这里暂时不深入实现了(TODO),详情参考:

UNITY实战进阶-OBB包围盒详解-6

关于3D的相交检测,这里我们用Unity Bounds提供的关于OBB的实现类。

更多的3D相交检测自定义实现,可以参考二维的实现扩展到3维,这里暂时不做进一步的学习记录。(大部分时候我们可以简化相交检测,比如讲3D简化到2D,扇形椭圆简化到圆形或点来实现相交碰撞检测)

线段相交

线段相交理论知识参考:

Unity3D C#数学系列之判断两条线段是否相交并求交点

LineIntersectionPoint

相交大前提:

  1. AB和CD必须共面。

如何判定AB和CD共面了?

通过计算ACD平面法线是否与AB垂直即可(2D向量可以省去这一步)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/// <summary>
/// 4个点是否共面
/// </summary>
/// <param name="a"></param>
/// <param name="b"></param>
/// <param name="c"></param>
/// <param name="d"></param>
/// <returns></returns>
public static bool IsCoplanarVector(Vector3 a, Vector3 b, Vector3 c, Vector3 d)
{
// 通过叉乘计算CA和CD的法线向量
var ab = b - a;
var ca = a - c;
var cd = d - c;
var normal = Vector3.Cross(ca, cd);
// 通过ab向量和CA和CD法线向量的点乘是否为0来判定是否垂直
// a,b,c,d从而判定出是否共面
if(Mathf.Approximately(Vector3.Dot(normal, ab), Mathf.Epsilon))
{
return true;
}
return false;
}

如何判定相交:

  1. 快速排斥和跨立实现判定是否相交

这里我采用跨立法来判定,跨立法是指两个线段相交必须满足两个线段的两个点分别在另一个线段的两侧

这里我们利用叉乘来判定两个向量的方向从而判定是否线段在另一个线段两侧。

2D向量:

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
/// <summary>
/// 2D向量叉乘
/// </summary>
/// <param name="a"></param>
/// <param name="b"></param>
/// <returns></returns>
public static float Cross(Vector2 a, Vector2 b)
{
return a.x * b.y - b.x * a.y;
}

/// <summary>
/// 4个点是否ab和cd线段是否相交
/// </summary>
/// <param name="a"></param>
/// <param name="b"></param>
/// <param name="c"></param>
/// <param name="d"></param>
/// <returns></returns>
public static bool IsCross(Vector2 a, Vector2 b, Vector2 c, Vector2 d)
{
// 通过叉乘计算CA和CD的法线向量
var ab = b - a;
var ac = c - a;
var ad = d - a;
// 叉乘判定两个向量的方向
// 判定ab是否在cd两侧
// 以ab为基准,如果ab叉乘ac或ad大于0表示逆时针反之顺时针,0表示两者方向相同
// 那么c和d要在ab同一侧,则ab叉乘ac的结果和ab叉乘ad的结果相乘需要>0
if (Vector2Utilities.Cross(ab, ac) * Vector2Utilities.Cross(ab, ad) >= 0)
{
return false;
}
// 同理以cd为基准,如果ca和cb叉乘结果相乘>0则表示a和b在cd同一侧
var ca = a - c;
var cb = b - c;
var cd = d - c;
if (Vector2Utilities.Cross(cd, ca) * Vector2Utilities.Cross(cd, cb) >= 0)
{
return false;
}
return true;
}

3D向量:

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
/// <summary>
/// 4个点是否ab和cd线段是否相交
/// </summary>
/// <param name="a"></param>
/// <param name="b"></param>
/// <param name="c"></param>
/// <param name="d"></param>
/// <returns></returns>
public static bool IsCross(Vector3 a, Vector3 b, Vector3 c, Vector3 d)
{
// 通过叉乘计算CA和CD的法线向量
var ab = b - a;
var ac = c - a;
var ad = d - a;
// 叉乘判定两个向量的方向
// 判定ab是否在cd两侧
// 以ab为基准,如果ac和ad叉乘结果再点乘>0则表示c和d在ab同一侧
// 3D向量叉乘是向量,通过计算两个叉乘向量的点乘判定是否同方向从而判定是否在一侧
if (Vector3.Dot(Vector3.Cross(ab, ac), Vector3.Cross(ab, ad)) >= 0)
{
return false;
}
// 同理以cb为基准,如果ca和cb叉乘结果再点乘>0表示a和b在cd同一侧
var ca = a - c;
var cb = b - c;
var cd = d - c;
if (Vector3.Dot(Vector3.Cross(cd, ca), Vector3.Cross(cd, cb)) >= 0)
{
return false;
}
return true;
}

如何计算交点:

  1. 几何法分析出交点

让我们直接看下结论:

LineIntersectionFormula

这个结论主要是通过2D平面几何的相似三角形以及向量叉乘在的集合解释是平行四边形面积(两个三角形面积之和)推到而来的。

LineIntersectionPicture

详情推到参考:

Unity3D C#数学系列之判断两条线段是否相交并求交点

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
/// <summary>
/// 计算AB与CD两条线段的交点.
/// </summary>
/// <param name="a">A点</param>
/// <param name="b">B点</param>
/// <param name="c">C点</param>
/// <param name="d">D点</param>
/// <param name="intersectPos">AB与CD的交点</param>
/// <returns>是否相交 true:相交 false:未相交</returns>
public static bool GetIntersectPoint(Vector2 a, Vector2 b, Vector2 c, Vector2 d, out Vector2 intersectPos)
{
intersectPos = Vector2.zero;
// Note:
// 1. 2D向量不需要判定共面
if (!IsCross(a, b, c, d))
{
return false;
}
// 根据推到结论
// AO / AB = Cross(CA, CD) / Cross(CD, AB)
// O = A + Cross(CA, CD) / Cross(CD, AB) * AB
var ab = b - a;
var ca = a - c;
var cd = d - c;
Vector3 v1 = Vector3.Cross(ca, cd);
Vector3 v2 = Vector3.Cross(cd, ab);
float ratio = Vector3.Dot(v1, v2) / v2.sqrMagnitude;
intersectPos = a + ab * ratio;
return true;
}

LineIntersectionPointCapture

可以看到我们成功可视化算出了两条线段相交的点。

那么如果我们不考虑两条线段相交,单纯想求得两个线段(包含延长线)上的交点时,应该怎么计算了?

实际上相似三角形的推论即使两个线段不相交也是成立的,所以我们唯一要确保的就是两个线段不平行,只有两个线段不平行才有交点。所以我们不需要判定线段两个点是否在两侧只需判定两个线段是否平行,剩下的步骤和之前一致即可。

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
/// <summary>
/// 4个点是否ab和cd线段是否平行
/// </summary>
/// <param name="a"></param>
/// <param name="b"></param>
/// <param name="c"></param>
/// <param name="d"></param>
/// <returns></returns>
public static bool IsParallel(Vector2 a, Vector2 b, Vector2 c, Vector2 d)
{
// 通过叉乘计算AB和CD是否为0判定是否平行
var ab = b - a;
var cd = d - c;
return Mathf.Approximately(Vector2Utilities.Cross(ab, cd), Mathf.Epsilon);
}

/// <summary>
/// 计算AB与CD两条线段的交点(包含衍生直线)
/// </summary>
/// <param name="a">A点</param>
/// <param name="b">B点</param>
/// <param name="c">C点</param>
/// <param name="d">D点</param>
/// <param name="intersectPos">AB与CD的交点</param>
/// <returns>是否相交 true:相交 false:未相交</returns>
public static bool GetLineIntersectPoint(Vector2 a, Vector2 b, Vector2 c, Vector2 d, out Vector2 intersectPos)
{
intersectPos = Vector2.zero;
// Note:
// 1. 2D向量不需要判定共面
if (IsParallel(a, b, c, d))
{
return false;
}
// 根据推到结论
// AO / AB = Cross(CA, CD) / Cross(CD, AB)
// O = A + Cross(CA, CD) / Cross(CD, AB) * AB
var ab = b - a;
var ca = a - c;
var cd = d - c;
Vector3 v1 = Vector3.Cross(ca, cd);
Vector3 v2 = Vector3.Cross(cd, ab);
float ratio = Vector3.Dot(v1, v2) / v2.sqrMagnitude;
intersectPos = a + ab * ratio;
return true;
}

LineIntersectionPoint2Capture

可以看到我们通过不判定线段点是否在两侧,只判定平时依然计算出了两个线段在延长线上的交点。

实战

待添加……

注意事项

  • 复杂的3D碰撞检测我们可以映射到2D来简化运算量实现相同的效果

  • 能够用简单的形状实现效果尽量用简单的形状来实现避免过于复杂的碰撞检测判定

  • 在坐标平移旋转转换时注意先旋转后平移,不然先平移后旋转会导致坐标转换错误,因为先旋转后平移!=先平移后旋转

Github

CollisionDetectionStudy

学习总结

  1. 2D图形相交检测更多的是平面几何知识,通过将图形转换成纯数学定义来求解
  2. 向量里的点乘叉乘对于求解向量的角度和旋转方向以及两个向量的相对位置很有用

Reference

【Unity】图形相交检测

3D 碰撞检测

Unity3D-游戏中的技能碰撞检测

[包围盒]球,AABB,OBB

UNITY实战进阶-OBB包围盒详解-6

unity3d:两条线段相交并求交点坐标

Unity3D C#数学系列之判断两条线段是否相交并求交点

前言

本章节博客是在项目需求设计支持Lua AI系统的前提下,驱动深入学习行为树设计实现。关于AI的相关基础概念学习可以参考:Programming Game AI by Example。而本章节的重点就是行为树,最终目的是实现一份Unity里能够支持项目级使用的支持Lua测使用AI的行为树框架设计。源代码考虑到公司正在使用所以暂时不放源码,大家可以参考设计思路自行实现。

行为树

行为树介绍

行为树首先要知道的一点,是一颗树形结构,树里包含控制决策走向的控制节点和负责展示表现的叶子结点。

行为树抽象一个角色的简单AI角色逻辑如下:
BehaviorTreeHierachy

优点:

  1. 可视化决策逻辑
  2. 可复用控制节点
  3. 逻辑和实现低耦合(叶子结点才是与上层逻辑挂钩的)

节点分类

从设计上来说节点功能划分为两类:

  1. 决策节点(决定行为树走向的非叶子节点)
  2. 行为节点(叶子节点决定最后的行为表现)

决策节点分类:

  1. 组合节点(序列节点(相当于and)、选择节点(相当于or)、并行节点(相当于for循环)等)
  2. 装饰节点(可以作为某种节点的一种额外的附加条件,如允许次数限制,时间限制,错误处理等。Note:装饰节点只有一个子节点)

行为节点分类:

  1. 条件节点(控制节点结果)
  2. 动作节点(实际的行为表现)

节点状态

所有的节点都有三种状态:

  1. 运行中(Running)
  2. 运行完毕(Success / Failed)
  3. 无效(Invalide)

正是通过上面的三种状态来实现行为树的行为决策的(所有节点都有返回状态)

节点选择

决策控制选择节点时需要有依据,这里的依据可以看做是选择某个节点的一个前提条件(Precondition),前提可以让我们快速判定分支避免不必要的子节点判定。控制节点相当于默认返回True,叶子节点可以作为选择节点的依据。

关于节点选择更多的优化(e.g. 1. 带优先级的选择 2. 带权值的选择……)参考:

行为树(Behavior Tree)实践(2)– 进一步的讨论

Note:
这里考虑到前置条件等价于组合节点的第一个节点设置条件节点,所以最终实战实现里没有实现前置条件(Precondition)

行为树更新

行为树更新方案:
2. 每次从上次运行的节点运行(打断时从根节点重新运行)

行为树中断

前面我们已经选择了方案2,这里就要考虑一下如何实现行为打断了。

从前面可以看出行为树节点是有Running状态的,也就是说只要满足子节点的层层条件后,可以持续执行一段时间(比如寻找目标地点寻路过去这段时间都是Running)。

试想一下游戏过程中所有的条件数据都是在实时变化的,执行寻找目标地点的条件可以在任何时候出现条件不满足的情况,那么我们如何打断正在执行的Running节点,让行为树判定执行一条符合条件的行为分支了?

通过了解,BehaviorDesigner里存在4中类型的中断:

  1. None
  2. Self
  3. Lower Priority
  4. Both

详情参考:行为树 中断的理解

核心都是通过标识决策节点的打断类型以及配合设置相应条件节点来实现额外的条件判定,从而实现其他子节点运行时也可以动态判定其他节点条件来实现打断运行节点。

BehaviorDesigner节点有抽象出节点优先级概念(大概就是树形结构的层级顺序优先级)。

这里只是为了实现一个简易版的行为树,所以就不实现那么复杂的打断机制了。

方案:
行为树运行时记录运行时跑过的需要重新评估的节点的运行结果状态(现阶段主要是指条件节点),每一次从根节点运行时都判定需要重新评估的节点运行状态是否有变化,有变化表示条件有变行为树需要打断执行。

为什么使用行为树

Programming Game AI by Example的介绍里可以看到相比状态机(FSM)行为树有以下几个好处:

  1. 使用行为树解决了状态机(FSM)过于复杂后维护成本高不利于扩展的问题。
  2. 行为树决策节点重用性高
  3. 行为树可视化编辑器能高效的查看行为树执行情况

行为树实战

目标:

  1. 实现一套可用于项目级别使用的支持Lua版编写AI的Unity行为树(深入理解行为树的原理和实现)

设计:

  1. 使用黑板模式作为单颗行为树的数据中心解决方案
  2. 使用Unity原生API实现行为树节点编辑器,支持高效方便的行为树编辑导出调试工具
  3. 使用Json(至于性能问题未来可选择高效的Json库,这里主要看中Json的可读性)作为行为树数据序列化反序列化方案
  4. 使用C#开发一套兼容CS和Lua的行为树框架(方便未来直接用于作为CS测的行为树解决方案),兼容Lua的核心思想是参数由string序列化,行为树节点反序列化构建时自行解析。
  5. 支持暂停,打断以及加载不同行为树Json的行为树框架
  6. 支持条件节点状态变化级别的行为树打断设计

数据管理

黑板模式

可以简单的理解成一个共享数据的地方,核心设计是一个K-V结构的Map容器(结合泛型的设计支持不同类型的数据访问),通过统一的数据访问方式实现行为树统一的数据访问。

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
 /// <summary>
/// 黑板模式,数据共享中心
/// </summary>
public class Blackboard
{
/// <summary>
/// 黑板数据集合中心
/// </summary>
protected Dictionary<string, IBlackboardData> mBlackboardDataMap;

public Blackboard()
{
mBlackboardDataMap = new Dictionary<string, IBlackboardData>();
}

/// <summary>
/// 添加黑板数据
/// </summary>
/// <param name="key"></param>
/// <param name="data"></param>
/// <returns></returns>
public bool AddData(string key, IBlackboardData data)
{
IBlackboardData value;
if(!mBlackboardDataMap.TryGetValue(key, out value))
{
mBlackboardDataMap.Add(key, data);
return true;
}
else
{
Debug.LogError(string.Format("黑板数据里已存在Key:{0}的数据,添加数据失败!", key));
return false;
}
}

/// <summary>
/// 移除黑板数据
/// </summary>
/// <param name="key"></param>
/// <returns></returns>
public bool RemoveData(string key)
{
return mBlackboardDataMap.Remove(key);
}

/// <summary>
/// 获取指定黑板数据
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="key"></param>
/// <returns></returns>
public T GetData<T>(string key)
{
var value = GetBlackboardData(key);
if (value != null)
{
return (value as BlackboardData<T>).Data;
}
else
{
Debug.LogError("返回默认值!");
return default(T);
}
}

/// <summary>
/// 更新黑板数据
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="key"></param>
/// <param name="data"></param>
/// <returns></returns>
public bool UpdateData<T>(string key, T data)
{
var value = GetBlackboardData(key);
if(value != null)
{
(value as BlackboardData<T>).Data = data;
return true;
}
else
{
Debug.LogError(string.Format("更新Key:{0}的数据失败!", key));
return false;
}
}

/// <summary>
/// 清除黑板数据
/// </summary>
public void ClearData()
{
mBlackboardDataMap.Clear();
}

/// <summary>
/// 获取黑板指定数据
/// </summary>
/// <param name="key"></param>
/// <returns></returns>
private IBlackboardData GetBlackboardData(string key)
{
IBlackboardData value;
if (!mBlackboardDataMap.TryGetValue(key, out value))
{
Debug.LogError(string.Format("找不到Key:{0}的黑板数据!", key));
}
return value;
}
}

/// <summary>
/// 黑板数据接口抽象
/// </summary>
public interface IBlackboardData
{

}

/// <summary>
/// 黑板数据泛型基类
/// </summary>
public class BlackboardData<T> : IBlackboardData
{
/// <summary>
/// 数据
/// </summary>
public T Data
{
get;
set;
}

public BlackboardData(T data)
{
Data = data;
}
}

#region 黑板数据常用数据类型定义
/// <summary>
/// 黑板数据整形数据
/// </summary>
public class IntBlackboardData : BlackboardData<int>
{
public IntBlackboardData(int data) : base(data)
{
}
}

......
#endregion

这里具体的黑板数据类型支持通过集成BlackboardData即可,这里就不放全部代码了。

黑板模式这里用于实现自定义变量并支持修改和读取判定,用于实现自定义变量控制AI逻辑。
黑板模式实现效果如下:
自定义变量节点
自定义变量参数面板
所有自定义变量展示面板

节点编辑器

关于节点编辑器,核心要了解Unity以下几个API:

  1. Event – UnityGUI事件响应编写接口类
    节点编辑器操作响应
  2. GUILayout.BeginArea() GUILayout.Toolbar() GUILayout.EndArea() EditorGUILayout.*** – GUI自定义面板UI显示接口
    节点编辑器操作面板
  3. GenericMenu – Unity编写自定义菜单的类
    行为树编辑器菜单
  4. BeginWindows() GUI.Window() EndWindows() GUI.DragWindow() – Unity编写节点窗口的类(支持拖拽的节点)
    行为树节点窗口
  5. Handles.DrawBezier() – 绘制节点曲线(Bezier曲线)连接线的接口
    行为树节点连线
  6. JsonUtility.ToJson() JsonUtility.FromJson() – Unity Json数据序列化反序列化存储
    行为树序列化反序列化存储

结合上面几个核心类,最终实现行为树节点编辑器效果图如下:
行为树节点编辑器效果展示

行为树框架

这里考虑项目正在使用,所以不方便放出源码,通过给出行为树的UML类图设计,大家可以参考思路自行实现(因为UML比较大所以分了两张图):
行为树UML类图1
行为树UML类图2

行为树打断

这里单独谈谈行为树的打断实现,在BehaviourDesigner里行为树的打断设计相对比较复杂,详情可以参考:
Unity3D Behavior Designer 行为树4 打断机制的实现

而这里实现的行为树打断是一种较为简单打断方式,就是在每棵行为树重新从根节点开始执行时,记录执行过的节点以及需要重新判定的节点以及状态,在下一次从根节点运行时执行需要重新判定的节点是否有状态变化来判定是否需要打断行为树Running等执行状态。而需要打断的节点是从上一次记录的执行过的节点里执行结果为Running的才需要打断,其他状态不用打断。

行为树驱动执行代码大致如下:
BTGraph.cs

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
 /// <summary>
/// 更新
/// </summary>
public void OnUpdate()
{
if (RootNode != null)
{
// 根节点处于非成功和失败状态(一般来说是Running)
if (!RootNode.IsTerminated)
{
// 节点处于非运行时需要清除上一次运行数据
if(!RootNode.IsRunning)
{
// 清除上一次运行数据(比如执行过的节点和需要重新评估的节点数据)
ClearRunningDatas();
}
// 重新评估需要重新判定的节点,看状态是否有变化
if (ReevaluatedExecutedNodes())
{
// 重新判定节点运行结果有变化需要重置运行节点重新判定
DoAbortBehaviourTree();
}
RootNode.OnUpdate();
}
// 根节点处于成功或失败状态
else
{
// 清除上一次运行数据(比如执行过的节点和需要重新评估的节点数据)
ClearRunningDatas();
// 重新评估需要重新判定的节点,看状态是否有变化
if (ReevaluatedExecutedNodes())
{
// 重新判定节点运行结果有变化需要重置运行节点重新判定
DoAbortBehaviourTree();
}
// 判定行为树单次执行完后是否需要自动重启下一次执行
if (OwnerBT.RestartWhenComplete)
{
RootNode.OnUpdate();
}
}
}
}

/// <summary>
/// 执行终止行为树
/// </summary>
public void DoAbortBehaviourTree()
{
Debug.Log("DoAbortBehaviourTree()");
foreach (var executingnode in ExecutingNodesMap)
{
// 只有执行中的节点需要打断(避免执行完成的节点二次打断)
if(executingnode.Value.IsRunning)
{
executingnode.Value.OnConditionalAbort();
}
}
ClearRunningDatas();
}

而动态打断机制是通过指定特定节点(比如当前博主只支持条件节点)是否支持参与重新打断判定实现的。
BTBaseConditionNode.cs

1
2
3
4
5
6
7
8
/// <summary>
/// 是否可被重新评估
/// </summary>
/// <returns></returns>
protected override bool CanReevaluate()
{
return AbortType == EAbortType.Self;
}

公司项目没有继续做了,项目开源了:

BehaviourTreeForLua

Reference

行为树(Behavior Tree)实践(1)– 基本概念
行为树(Behavior Tree)实践(2)– 进一步的讨论
用800行代码做个行为树(Behavior Tree)的库(1)
用800行代码做个行为树(Behavior Tree)的库(2)
AI 行为树设计与实现-理论篇
行为树及其实现
unity 行为树 简单实现
基于Unity行为树设计与实现的尝试
游戏设计模式——黑板模式
Unity3D Behavior Designer 行为树4 打断机制的实现

前言

多年使用Unity经验的朋友对于列表单元肯定很熟悉,每个公司也都有着自己的一套单元格设计。本章节博客着重学习Unity里列表单元格框架的设计和实现,深入学习不同单元格框架的设计优劣。

列表

什么是列表了?
可以滚动,支持创建多个基于特定模板显示的单元格容器。

列表的功能需求(**(√)**表示本人已经实现的部分)?

  1. 滚动显示(支持滚动到指定位置)(√)
  2. 动态添加(支持任何位置添加元素)(√)
  3. 循环列表(支持从头滚到尾再接着滚动到头的循环方式)
  4. 不同大小单元格显示(支持自适应大小或者自定义传单元格大小)(√)
  5. 多种列表类型显示(e.g. 横向,竖向,横向网格,竖向网格等)(√)
  6. 单元格构造自定义传参**(√)**
  7. 自定义滚动动画**(√)**
  8. 不同单元格朝向(e.g. 从左往右或从右往左。从上往下或者从下往上。)(√)
  9. 单元格矫正(最终单元格会滚动到某个最近的单元格位置)(√)
  10. 单元格对象池(e.g. 单元格逻辑对象(Object)对象池。单元格实体对象(GameObject)对象池。)(√)
  11. 单元格嵌套滚动(e.g. 嵌套不同方向的单元格容器滚动支持)(√)
  12. 本地单元格模拟创建查看效果(e.g. Inspector支持快速模拟查看排版等)(√)

单元格容器

这里关于单元格容器,这里主要实现了两套:

  1. 支持不同滚动方向不同初始化朝向支持不同大小(手动传Size或者自动计算Size)嵌套滚动单元格的一套通用单元格容器。
  2. 支持两侧深度滚动显示的单元格容器(这一套实现的效果比较特别所以单独实现的)
    其中本人主要以第一套(以前在公司学习到的一套实现方案,后来在此基础上扩展支持了很多功能)为重点来学习实战。

单元格实现分析

这里通过学习了解市面上常见的单元格实现来了解常见的单元格实现方案:

LoopScrollRect

LoopScrollRect
这一套是钱康来大佬编写分享的一套支持循环列表的实现方案。
通过查看源码,可以了解到,这一套实现核心是基于ContentSizeFitter,LayoutGroup和LayoutElement相关组件来实现动态计算单元格位置显示循环滚动以及不同大小等效果的。
亮点分析:

  1. 使用了原生Scroller,但并不依赖原生ScrolRect那套来计算滚动,这样为支持循环列表打下了基础。
  2. 单元格位置数据与单元格逻辑对象分离,可以做到动态计算单元格显示时只有需要显示的才有逻辑单元格对象(避免New大量的单元格)

缺点分析:

  1. 虽然基于LayoutGroup和LayoutElement,但并非正向通过他们的排版功能得到不同单元格大小显示,而是反向设置LayoutElement来通过LayoutUtility.GetPreferredHeight()等接口方法来实现正确的计算滚动排版。这儿也就意味着做不到自动计算单元格大小的功能。
  2. 单元格显示是通过SendMessage的形式触发,感觉这种方式效率可能不如直接调用接口方法来的快(个人感觉)。
    核心获取单元格大小的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
protected override float GetSize(RectTransform item)
{
float size = contentSpacing;
if (m_GridLayout != null)
{
size += m_GridLayout.cellSize.y;
}
else
{
size += LayoutUtility.GetPreferredHeight(item);
}
return size;
}

单元格初始化显示触发代码:

1
2
3
4
public override void ProvideData(Transform transform, int idx)
{
transform.SendMessage("ScrollCellIndex", idx);
}

FancyScrollView

FancyScrollView
FancyScrollViewExhibition
这一套是在UWA上看到的,貌似是日本开发者开发的。
通过上面的效果图可以看到,这一套实现了很强大的结合自定义动画或者Shader的单元格容器框架。
通过查看源代码可以发现,这一套非常强大的一点是通过自定义实现Scroller强大的滚动单元格容器需要的滚动相关的计算和回调支持。
上次代码只需要通过ScrollView和Scroller接口设置显示数据以及单元格模板等就能做到很好的单元格容器滚动效果。
亮点分析:

  1. 没有依赖原生Scroller,自定义实现了Scroller,可以更加容易的支持循环列表。
  2. 动态计算显示与否是通过当前滚动到的位置来判定,而单元格位置信息并没有耦合到单元格对象里,这样可以做到逻辑对象数量等于最大可显示的数量。
  3. 更新显示回调(UpdatePosition)里有传入位置数据,可以基于位置数据做动画(Animator)或者Shader相关的表现展示
  4. 不依赖于Scroller,LayoutGroup等组件,开销更低

缺点分析:

  1. 单元格不支持不同大小,都是按预制件统一大小来计算

super-scrollview

ugui-super-scrollview-example
这一套是Unity Asset Store里出售的一套滚动单元格插件。
让我们先来一张效果图展示:
SuperScrollViewExhibition
从上面可以看到,SuperScrollView可以做到很多效果(e.g. 翻页,动态加载,动态大小变化,滚动到指定位置,动态增删单元格,不同朝向单元格容器显示……)
基本上能想到的单元格容器效果,这个插件都支持。
接下来让我们结合源码和实例来分析下这一套单元格容器的好坏:
亮点分析:

  1. 单元格位置数据与逻辑对象分离,可以有效的减少逻辑单元格对象数量
  2. 自定义的单元格大小抽象,可以方便的自定义传入不同大小的单元格并显示
  3. 支持不同朝向的单元格容器显示(核心是反向初始化和计算单元格位置显示)

缺点分析:

  1. 使用了ScrollRect部分功能(特别是容器滚动部分),这一点导致要做循环列表就很困难

后续本人的单元格容器很大程度上跟SuperScrollView更相近(指功能设计上而非代码设计)。

实战单元格容器

通过学习了解市面上的单元格容器实现,可以看到有几个比较重要的点:

  1. 不依赖于ScrollRect滚动,自定义分离单元格位置数据和单元格逻辑对象,是实现循环列表和减少逻辑单元格对象数量的关键。
  2. 大部分都不支持自动计算大小,需要手动计算传递单元格大小,相对来说没那么方便,但开销会小。

接下来让我们看看博主实战最终实现的效果(主要讲解第一套单元格):
LeftToRightScene

TopToBottomScene

HorizontalGalleryDemoScene

ChatMessageListScene

ChangeItemSizeScene

ClickAndLoadMoreScene

GridViewScene

PageViewScene

SpinDatePickerScene

SelectAndDeleteOrMoveScene

上面展示了我支持的各类单元格容器,比如横向单元格容器,竖向单元格容器,横向网格单元格容器,竖向网格单元格容器,动态大小等各种用法。
DepthScrollExhibition
第三章图主要展示的是另一套单元格容器,支持左右两侧指定深度滚动的单元格容器。

Note:

基于ContentSizeFitter和LayoutGroup的自动计算大小测试性能开销比较大最后决定不予支持了。

深入讲解

让我们通过横向单元格的面板来大致了解下单元格都支持哪些功能:
!(HorizontalContainerInspector)(/img/Unity/CellContainer/HorizontalContainerInspector.png)
从面板上可以看出,博主的单元格容器主要支持了以下几个功能:

  1. 单元格滚动自动矫正以及相关速度设置(最终确保滚动到单元格正中心位置)
  2. 支持不同朝向的单元格创建显示
  3. 支持嵌套单元格容器(不同朝向的单元格容器可以嵌套滚动)
  4. 支持单元格的间距和初始位置排版设置
  5. 支持设置多预制件用于绑定不同逻辑单元格对象显示在同一个单元格容器下
  6. 支持编辑器下快速模拟创建单元格查看排版

单元格容器核心实现原理:

  1. 通过手动传单元格大小或者自动计算单元格大小来得到每个单元格的大小数据
  2. 通过累加计算出总的可滚动Content大小,同时计算出单元格的抽象单元格位置
  3. 结合IBeginDragHandler, IDragHandler, IEndDragHandler接口来判定拖拽状态(比如是否开始或结束拖拽,拖拽方向等),以及实现嵌套单元格的拖拽事件判定以及分发
  4. Content结合ScrollRect来实现滚动回调(OnValueChanged)触发滚动计算判定,结合单元格的抽象位置来判定每个单元格的显隐状态。同时判定是否满足单元格矫正条件
  5. 通过IEndDragHandler响应结束拖拽时判定是否需要执行单元格矫正
  6. 向上层暴露OnShow,OnVisibleScroll,MoveTOIndex,OnHide等接口来实现单元格逻辑流程显示回收(BaseScrollContainer:bindContainerCallBack()接口传递)

结合UML类图来理解下单元格容器的设计:
CellContainerUML

首先先放一段完整的创建单元格显示的事例代码,后续会一一讲解具体实现细节流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
RightToLeftContainer.bindContainerCallBack(onCellShow);
RightToLeftContainer.setCellDatasByCellCount(20);

/// <summary>
/// 单元格显示回调
/// </summary>
/// <param name="cellindex"></param>
/// <param name="cellinstance"></param>
private void onCellShow(int cellindex, GameObject cellinstance)
{
var toptobottomcell = cellinstance.GetComponent<ShowCellIndexCell>();
if (toptobottomcell == null)
{
toptobottomcell = cellinstance.AddComponent<ShowCellIndexCell>();
}
toptobottomcell.init(cellindex);
}

接下来让我们看看单元格容器的细节实现:
CellData.cs(单元格相关抽象

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
public class CellData : IRecycle
{
/// <summary>
/// 当前Cell索引
/// </summary>
public int CellIndex
{
get;
set;
}

/// <summary>
/// 拥有者单元格
/// </summary>
protected BaseScrollContainer mOwnerContainer;

/// <summary>
/// Cell实例对象
/// </summary>
public GameObject CellGO
{
get;
protected set;
}

/// <summary>
/// Cell实例对象的RectTransform
/// </summary>
public RectTransform CellGORectTransform
{
get;
protected set;
}

/// <summary>
/// 单元格大小(宽和高)
/// </summary>
private Vector2 mCellSize;

/// <summary>
/// 单元格预制件索引
/// </summary>
public int CellPrefabIndex
{
get;
private set;
}


......

public void onCreate()
{
//Debug.Log("NewCellData:onCreate()");
}


public void onDispose()
{
//Debug.Log("NewCellData:onDispose()");
CellIndex = -1;
mOwnerContainer = null;
CellGO = null;
CellGORectTransform = null;
CellPrefabIndex = -1;
mCellSize = Vector2.zero;
mRectPos = Vector2.zero;
mRectAbsPos = Vector2.zero;
CellRect.Set(0.0f, 0.0f, 0.0f, 0.0f);
}

public CellData()
{
CellIndex = -1;
mOwnerContainer = null;
CellGO = null;
CellGORectTransform = null;
CellPrefabIndex = -1;
mCellSize = Vector2.zero;
mRectPos = Vector2.zero;
mRectAbsPos = Vector2.zero;
CellRect.Set(0.0f, 0.0f, 0.0f, 0.0f);
}

/// <summary>
/// 初始化单元格实例对象
/// </summary>
/// <param name="cellinstance"></param>
public void init(GameObject cellinstance)
{
if(cellinstance != null)
{
CellGO = cellinstance;
// 不同单元格容器公用同一个模板对象可能会出现锚点不一致问题,所以每次强制设置
CellGORectTransform = CellGO.transform as RectTransform;
CellGORectTransform.anchorMax = AnchorMax;
CellGORectTransform.anchorMin = AnchorMin;
CellGORectTransform.pivot = Pivot;
updateCellSizeAndPosition();
if (!CellGO.activeSelf)
{
CellGO.SetActive(true);
}
}
else
{
CellGO = null;
CellGORectTransform = null;
}
}

/// <summary>
/// Cell数据清除
/// </summary>
public void clear()
{
mOwnerContainer = null;
CellGO = null;
CellGORectTransform = null;
mCellSize = Vector2.zero;
mRectPos = Vector2.zero;
CellRect = Rect.zero;
ObjectPool.Singleton.push<CellData>(this);
}

......
}

上面列举了单元格参与容器显示判定计算比较重要的成员变量以及生命周期函数,可以看出单元格的大小和位置以及显示区域信息会用作单元格容器滚动时的判定数据,从而判定是否需要显示特定单元格。

从前面的事例代码可以看到单元格的创建可以手动指定单元格大小,不传会用默认预制件大小

设定单元格容器回调后触发setCellDatasByCellCount()等设置容器数量相关接口后,触发单元格以及单元格容器相关数据的初始化和判定以及滚动判定监听:
BaseScrollContainer.cs(单元格容器基类)

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
// Use this for initialization
public virtual void Start()
{
ScrollRect.onValueChanged.AddListener(onScrollChanged);
TryCorrectData();
}

/// <summary>
/// 滚动回调刷新Cell显示
/// </summary>
/// <param name="scrollpos"></param>
protected override void onScrollChanged(Vector2 scrollpos)
{
if (mCellDatas != null)
{
updateScrollValue();
mMaskRect.x = mAvalibleScrollDistance * ScrollRect.horizontalNormalizedPosition;
for (int i = 0; i < mCellDatas.Count; i++)
{
onCellDisplay(i);
}
checkCellPostionCorrect(mCurrentScrollDir);
}
}

/// <summary>
/// Set container celldatas by pass data
/// 设置Container的cell数据通过列表数据
/// </summary>
/// <param name="prefabindexlist">预制件索引列表</param>
/// <param width="cellsizelist">单元格大小列表(为空表示采用预制件默认大小)</param>
/// <param name="scrollnormalizedposition">单元格初始滚动位置</param>
public void setCellDatasByDataList(List<int> prefabindexlist, List<Vector2> cellsizelist = null, Vector2? scrollnormalizedposition = null)
{
var celldatalist = createNormalCellDataList(prefabindexlist, cellsizelist);
setCellDatas(celldatalist, scrollnormalizedposition);
}

/// <summary>
/// Set container celldatas by pass cell count
/// 设置Container的cell数据通过单元格数量
/// </summary>
/// <param name="prefabindexlist">预制件索引列表</param>
/// <param width="cellsizelist">单元格大小列表(为空表示采用预制件默认大小)</param>
/// <param name="scrollnormalizedposition">单元格初始滚动位置</param>
public void setCellDatasByCellCount(int cellcount, Vector2? scrollnormalizedposition = null)
{
var celldatalist = createNormalCellDataListWithCount(cellcount);
setCellDatas(celldatalist, scrollnormalizedposition);
}

/// <summary>
/// Set container celldatas by pass celldata list
/// 设置Container的cell数据
/// </summary>
/// <param name="celldatas">所有的Cell数据</param>
/// <param name="scrollnormalizedposition">单元格初始滚动位置</param>
public void setCellDatas(List<CellData> celldatas, Vector2? scrollnormalizedposition = null)
{
......
}

/// <summary>
/// Update container relative datas
/// 更新容器数据
/// </summary>
/// <param name="scrollnormalizaedposition">单元格初始滚动位置</param>
/// <param name="keeprectcontentpos">是否保持rect content的相对位置(优先于scrollnormalizaedposition)</param>
protected abstract void updateContainerData(Vector2? scrollnormalizaedposition = null, bool keeprectcontentpos = false);

......

因为单元格的流程和细节还是比较多,这里讲解的不太全面,很多地方没有说到,但考虑到项目还在用这两套单元格滚动,所以展示不打算放出源码,等未来实际成熟再分享源码。

亮点分析:

  1. 支持嵌套单元格滚动(通过判定滚动方向手动向上传递滚动事件实现)
  2. 支持手动传Size
  3. 支持不同单元格模板以及不同单元格逻辑对象的显示

缺点分析:

  1. 滚动单元格依赖于ScrollRect的滚动机制需要一开始就计算出总的单元格大小之和,导致很难支持循环列表。
  2. 滚动列表要一开始就知道单元格的大小,导致一开始如果有过多的动态大小会导致单元格初始化前的计算开销过大。

未来优化

  1. 设计成支持纯虚拟的滚动列表,可以在滚动时再请求单元格大小信息做到动态更新ScrollRect滚动容器大小,动态单元格大小在初始化时的开销过大。
  2. 设计成不基于ScrollRect的滚动机制,为实现循环列表做准备

—————————-2021/4/20更新开始——————————-

开始着手分离单元格位置和单元格逻辑对象优化单元格逻辑对象过多问题,确保只需创建可见的单元格逻辑对象,减少不必要的逻辑对象创建开销。(已设计成回调似方式来优化逻辑对象问题)

—————————-2021/4/20更新结束——————————-

Note:

  1. 源代码里默认带了一套组件绑定(结合代码生成)的方案,感兴趣的朋友可以自己了解下
  2. 源代码里也默认带了ObjectPool和GameObjectPool两种对象池的实现,感兴趣的朋友可以自己了解下

Github地址

ScrollContainer

Reference

LoopScrollRect
FancyScrollView
ugui-super-scrollview-example
Unity3D研究院之ContentSizeFitter同步立即响应回调

前言

游戏开发过程中我们经常会需要访问一些指定节点,组件和脚本。

常规方式会通过编写GetChild(?)和GetComponent()代码的方式去获取节点和组件,但这样对于开发来说并不高效同时运行时的GetChild(?)和GetComponent()也是有运行开销的。

为了寻求更高效,高性能和便捷的方式,这里引出组件绑定的工具方案。

实现一套通用的组件绑定方案,为未来游戏快速开发打下基础。

组件绑定原理

  1. 通过Object数组实现通用类型的组件绑定存储
  2. 通过自定义Inspector实现绑定对象,绑定对象组件绑定选择以及模板类型选择,代码生成等UI操作界面
  3. 通过模板+代码生成实现组件绑定的自定义快速代码生成(通过生成partial代码不入侵原始逻辑代码)
  4. 结合自定义ScriptableObject自定义不同模板类型的不同代码输出目录实现自定义代码目录输出设置

功能实现

  1. 支持通节点不同组件的绑定
  2. 支持节点自定义变量名和注释定义
  3. 支持自定义代码模板的选择生成
  4. 支持组件绑定代码不入侵功能代码(利用partial class)功能生成代码
  5. 支持不同模板类型代码生成输出目录设置

实战实现

组件绑定脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/// <summary>
/// 节点绑定类
/// </summary>
[DisallowMultipleComponent]
public class ComponentBinder : MonoBehaviour
{
/// <summary>
/// 绑定类型索引值
/// </summary>
public int BindCodeTypeIndex;

/// <summary>
/// UI节点数据
/// </summary>
public List<ComponentBindData> NodeDatas = new List<ComponentBindData>();
}
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
/// <summary>
/// 组件绑定节点信息
/// </summary>
[Serializable]
public class ComponentBindData
{
/// <summary>
/// 节点对象
/// </summary>
[SerializeField]
public UnityEngine.Object NodeTarget;

/// <summary>
/// 变量别名(用于生成代码)
/// </summary>
[SerializeField]
public string VariableAlias;

/// <summary>
/// 节点描述
/// </summary>
[SerializeField]
public string NodeDes;

public ComponentBindData(UnityEngine.Object target)
{
NodeTarget = target;
NodeDes = string.Empty;
}
}

从上面的可以看出节点组件和脚本的数据绑定核心是通过脚本序列化Object[]数组以及相关数据的方式实现绑定节点数据存储的。

组件绑定自定义面板

1
2
3
4
5
6
7
8
9
/// <summary>
/// 组件绑定自定义Editor
/// </summary>
[CustomEditor(typeof(ComponentBinder))]
[CanEditMultipleObjects]
public class ComponentBinderEditor : Editor
{
******
}

这部分代码主要是实现ComponentBinder的Inspector自定义GUI显示,从而实现我们想要的自定义UI操作。

组件绑定模板代码生成

1
2
3
4
5
6
7
/// <summary>
/// CompoenntBinder代码生成工具
/// </summary>
public static class ComponentBinderCodeGenerator
{
******
}
1
2
3
4
5
6
7
/// <summary>
/// 模板数据处理类
/// </summary>
public class TTemplate
{
******
}

此文件主要是通过自定义选择的模板文件***.txt结合TTemplate工具类实现模板文件txt里的内容自定义生成输出我们想要的代码。

窗口模板文件示例:

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
/*
* Description: #FileName#.cs
* Author: #Author#
* Create Date: #CreatedDate#
*/

using UnityEngine;
using UnityEngine.UI;
using TH.Modules.UI;

namespace Game.Modules.UI
{
/// <summary>
/// #ClassName#窗口
/// </summary>
public class #ClassName# : BaseWindow
{
public #ClassName#()
{

}

/// <summary>
/// 添加监听
/// </summary>
protected override void addListeners()
{
base.addListeners();
}

/// <summary>
/// 窗口显示
/// </summary>
protected override void onShow()
{
base.onShow();
}

/// <summary>
/// 移除监听
/// </summary>
protected override void removeListeners()
{
base.removeListeners();
}

/// <summary>
/// 窗口销毁
/// </summary>
protected override void onDestroy()
{
base.onDestroy();
}
}
}

窗口组件绑定模板示例:

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
/*
* Description: #FileName#.cs
* Author: #Author#
* Create Date: #CreatedDate#
*/

using UnityEngine;
using UnityEngine.UI;
using TH.Modules.UI;

namespace Game.Modules.UI
{
/// <summary>
/// #ClassName#窗口的组件绑定
/// </summary>
public partial class #ClassName#
{
#MEMBER_DEFINITION_LOOP#
/// <summary> #NodeDes# /// </summary>
private #NodeType# #NodeName#;#MEMBER_DEFINITION_LOOP#

/// <summary>
/// 缓存组件
/// </summary>
protected override void cacheComponents()
{
base.cacheComponents();
#MEMBER_INIT_LOOP#
#NodeName# = #NodeMemberName#.NodeDatas[#NodeIndex#].NodeTarget as #NodeType#;#MEMBER_INIT_LOOP#
}

/// <summary>
/// 释放组件
/// </summary>
protected override void disposeComponents()
{
base.disposeComponents();
#MEMBER_DISPOSE_LOOP#
#NodeName# = null;#MEMBER_DISPOSE_LOOP#
}
}
}

其他模板生成参考文件:

CellUIBinder.txt,CellUITemplate.txt,GameObjectUIBinder.txt

组件绑定设置

1
2
3
4
5
6
7
8
9
/// <summary>
/// ComponentBindSetting.cs
/// 组件绑定设置数据
/// </summary>
[CreateAssetMenu(fileName = "ComponentBinderSetting", menuName = "ScriptableObjects/ComponentBinderSetting", order = 1)]
public class ComponentBinderSetting : ScriptableObject
{
******
}
1
2
3
4
5
6
7
8
9
10
/// <summary>
/// ComponentBinderSettingEditor.cs
/// 组件绑定自定义Editor
/// </summary>
[CustomEditor(typeof(ComponentBinderSetting))]
[DisallowMultipleComponent]
public class ComponentBinderSettingEditor : Editor
{
******
}

实战使用

  • 组件绑定设置

    ComponentBinderSettingUI

    通过自定义模板类型的代码输出路径设置实现自定义代码输出设置

  • 组件绑定选择

    ComponentBindChosenUI

    1. 支持通节点不同组件的绑定
    2. 支持节点自定义变量名和注释定义
  • 自定义绑定节点代码注释

    CustomScriptNotation

  • 窗口模板组件绑定设置以及代码生成

    WindowTemplateBindUsing

    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
    /*
    * Description: WindowBindPrefab.cs
    * Author: TONYTANG
    * Create Date: 2022//05/29
    */

    using UnityEngine;
    using UnityEngine.UI;
    using TH.Modules.UI;

    namespace Game.Modules.UI
    {
    /// <summary>
    /// WindowBindPrefab窗口
    /// </summary>
    public class WindowBindPrefab : BaseWindow
    {
    public WindowBindPrefab()
    {

    }

    /// <summary>
    /// 添加监听
    /// </summary>
    protected override void addListeners()
    {
    base.addListeners();
    }

    /// <summary>
    /// 窗口显示
    /// </summary>
    protected override void onShow()
    {
    base.onShow();
    }

    /// <summary>
    /// 移除监听
    /// </summary>
    protected override void removeListeners()
    {
    base.removeListeners();
    }

    /// <summary>
    /// 窗口销毁
    /// </summary>
    protected override void onDestroy()
    {
    base.onDestroy();
    }
    }
    }

    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
    /*
    * Description: WindowBindPrefabBinder.cs
    * Author: TONYTANG
    * Create Date: 2022//05/29
    */

    using UnityEngine;
    using UnityEngine.UI;
    using TH.Modules.UI;

    namespace Game.Modules.UI
    {
    /// <summary>
    /// WindowBindPrefab窗口的组件绑定
    /// </summary>
    public partial class WindowBindPrefab
    {

    /// <summary> 根GameObject /// </summary>
    private GameObject _rootGo;
    /// <summary> 根RectTransform /// </summary>
    private RectTransform _rootRect;
    /// <summary> 背景图 /// </summary>
    private Image imgBg;
    /// <summary> 左侧按钮 /// </summary>
    private Button btnLeftSwitch;
    /// <summary> 右侧按钮 /// </summary>
    private Button btnRightSwitch;

    /// <summary>
    /// 缓存组件
    /// </summary>
    protected override void cacheComponents()
    {
    base.cacheComponents();

    _rootGo = mComponentBinder.NodeDatas[0].NodeTarget as GameObject;
    _rootRect = mComponentBinder.NodeDatas[1].NodeTarget as RectTransform;
    imgBg = mComponentBinder.NodeDatas[2].NodeTarget as Image;
    btnLeftSwitch = mComponentBinder.NodeDatas[3].NodeTarget as Button;
    btnRightSwitch = mComponentBinder.NodeDatas[4].NodeTarget as Button;
    }

    /// <summary>
    /// 释放组件
    /// </summary>
    protected override void disposeComponents()
    {
    base.disposeComponents();

    _rootGo = null;
    _rootRect = null;
    imgBg = null;
    btnLeftSwitch = null;
    btnRightSwitch = null;
    }
    }
    }

    更多的代码生成示例,参考Github源代码工程。

    GameObjectBinderUI

    CellBinderUI

重点知识

  1. 利用自定义UI结合Object[]实现自定义组件绑定数据序列化
  2. 利用代码生成使用partial实现组件绑定代码无缝插入工程代码实现快速替换和访问

Github

ComponentBinder