前言

游戏开发过程中为了无论资源加载异步还是逻辑异步,异步都是不可或缺的一部分。本章节是为了深入理解C#的异步编程以及Unity里异步编程使用而编写。

异步编程

Unity里最初的模拟异步编程概念的当之无愧是携程,但携程实际上是单线程通过每帧等待实现异步模拟运行的而非真正的异步编程。

异步编程的过程中我们往往会采用回调式的方式来编写代码,这回导致回调地狱,导致代码理解起来比较困难,有了Task异步编程我们可以实现类似同步式的代码编写来编写异步代码。

而本章节的重点是真正深入了解异步编程,C#里的最新的异步编程主要是通过Task

Task-based Asynchronous Pattern(TAP)

The core of async programming is the Task and Task objects, which model asynchronous operations. They are supported by the async and await keywords.

TAP uses a single method to represent the initiation and completion of an asynchronous operation.

从上面的介绍可以看出异步编程的核心是Task实现了异步操作,并且支持async和await关键词,其次TAP通过方法定义就完成了所有的异步操作初始化和方法定义。

注意事项

  1. await can only be used inside an async method.(await只能用在async标记的方法内)
  2. async methods need to have an await keyword in their body or they will never yield!(async标记的方法需要一个await关键词在方法定义内,不然这个异步方法不会暂停会同步执行)

Task

The Task class represents a single operation that does not return a value and that usually executes asynchronously. Task objects are one of the central components of the task-based asynchronous pattern first introduced in the .NET Framework 4. Because the work performed by a Task object typically executes asynchronously on a thread pool thread rather than synchronously on the main application thread, you can use the Status property, as well as the IsCanceled, IsCompleted, and IsFaulted properties, to determine the state of a task.

从介绍可以看出,异步编程主要是通过Task类的抽象,而Task的异步编程底层伴随着线程池,我们使用的人可以不用关心底层线程池的优化调度问题。通过Task抽象异步编程,Task可以返回异步运行状态IsCanceled,IsCompleted,IsFaulted等来查看异步运行状态。

通过实战使用,了解下Task的基础使用以及底层跨线程的设计:

Task基础使用

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
/// <summary>
/// 响应基础Task使用按钮点击
/// </summary>
public void OnBtnBasicTaskUsingClick()
{
// 创建一个Task1但不开始
Task task1 = new Task(mTaskActionDelegate, "Task1");
// 通过Task工厂创建一个Task2并等待完成
Task task2 = Task.Factory.StartNew(mTaskActionDelegate, "Task2");
task2.Wait();
// Task1开始
task1.Start();
Debug.Log($"Task1 has been launched. Main Thread:{Thread.CurrentThread.ManagedThreadId}");
// 等待Task1完成
task1.Wait();
// 通过Task.Run创建Task3并等待完成
Task task3 = Task.Run(()=>
{
Debug.Log($"ActionName:Task3");
Debug.Log($"Task:{Task.CurrentId} Thread:{Thread.CurrentThread.ManagedThreadId}");
});
Debug.Log($"Task3 has been launched. Main Thread:{Thread.CurrentThread.ManagedThreadId}");
task3.Wait();
}

/// <summary>
/// Task使用方法
/// </summary>
/// <param name="actionName"></param>
private void TaskAction(object actionName)
{
Debug.Log($"ActionName:{actionName}");
Debug.Log($"Task:{Task.CurrentId} Thread:{Thread.CurrentThread.ManagedThreadId}");
}

TaskBasicUsing

从上面的Task尝试可以看出,Task是在不同线程开启的,其次Task如果是单独的New是不会自动开始的,Task.Run和Task.Factory.StartNew才会直接开始一个Task。

Task使用Async和Await实现异步方法定义和等待

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>
/// 响应Task等待按钮点击
/// </summary>
private async void OnBtnAwaitTaskUsingClick()
{
var awaitTask = Task.Run(AwaitTaskAction);
Debug.Log($"AwaitTaskAction has been launched. Main Thread:{Thread.CurrentThread.ManagedThreadId}");
var sumResult = await awaitTask;
Debug.Log($"sumResult:{sumResult}");
Debug.Log($"DateTime.Now.Millisecond:{DateTime.Now.Millisecond}");
}

/// <summary>
/// 等待的Task方法
/// </summary>
/// <returns></returns>
private async Task<int> AwaitTaskAction()
{
Debug.Log($"AwaitTaskAction Start");
Debug.Log($"DateTime.Now.Millisecond:{DateTime.Now.Millisecond}");
await Task.Delay(1000);
Debug.Log($"AwaitTaskAction After Task.Delay(1)");
Debug.Log($"DateTime.Now.Millisecond:{DateTime.Now.Millisecond}");
int sum = 0;
for(int i = 0; i < 1000000000; i++)
{
sum++;
}
await Task.Delay(2000);
Debug.Log($"AwaitTaskAction End");
Debug.Log($"DateTime.Now.Millisecond:{DateTime.Now.Millisecond}");
return sum;
}

AwaitTaskUsing

通过Async关键词,我把OnBtnAwaitTaskUsingClick和AwaitTaskAction方法都定义成了异步方法(既可以通过Task异步调用的方法)。然后结合Await关键词实现了等待异步方法AwaitTaskAction执行完后返回运算结果并打印的效果,可以看到这样一来我们的异步代码编写结合Await关键词就跟同步代码一般是一个线性流程,同时异步方法内通过Task.Delay等类似携程的等待方法实现指定时间指定条件的等待。

那么Async和Await关键词是如何实现异步等待效果的了,让我们结合反编译看一下底层实现:

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
[AsyncStateMachine(typeof (GameLauncher.<OnBtnAwaitTaskUsingClick>d__9))]
[DebuggerStepThrough]
private void OnBtnAwaitTaskUsingClick()
{
GameLauncher.<OnBtnAwaitTaskUsingClick>d__9 stateMachine = new GameLauncher.<OnBtnAwaitTaskUsingClick>d__9();
stateMachine.<>4__this = this;
stateMachine.<>t__builder = AsyncVoidMethodBuilder.Create();
stateMachine.<>1__state = -1;
stateMachine.<>t__builder.Start<GameLauncher.<OnBtnAwaitTaskUsingClick>d__9>(ref stateMachine);
}

[AsyncStateMachine(typeof (GameLauncher.<AwaitTaskAction>d__10))]
[DebuggerStepThrough]
private Task<int> AwaitTaskAction()
{
GameLauncher.<AwaitTaskAction>d__10 stateMachine = new GameLauncher.<AwaitTaskAction>d__10();
stateMachine.<>4__this = this;
stateMachine.<>t__builder = AsyncTaskMethodBuilder<int>.Create();
stateMachine.<>1__state = -1;
stateMachine.<>t__builder.Start<GameLauncher.<AwaitTaskAction>d__10>(ref stateMachine);
return stateMachine.<>t__builder.Task;
}

[CompilerGenerated]
private sealed class <OnBtnAwaitTaskUsingClick>d__9 : IAsyncStateMachine
{
public int <>1__state;
public AsyncVoidMethodBuilder <>t__builder;
public GameLauncher <>4__this;
private Task<int> <awaitTask>5__1;
private int <sumResult>5__2;
private int <>s__3;
private TaskAwaiter<int> <>u__1;

public <OnBtnAwaitTaskUsingClick>d__9()
{
base..Ector();
}

void IAsyncStateMachine.MoveNext()
{
int num1 = this.<>1__state;
try
{
TaskAwaiter<int> awaiter;
int num2;
if (num1 != 0)
{
this.<awaitTask>5__1 = Task.Run<int>(new Func<Task<int>>((object) this.<>4__this, __methodptr(AwaitTaskAction)));
Debug.Log((object) string.Format("AwaitTaskAction has been launched. Main Thread:{0}", (object) Thread.CurrentThread.ManagedThreadId));
awaiter = this.<awaitTask>5__1.GetAwaiter();
if (!awaiter.IsCompleted)
{
this.<>1__state = num2 = 0;
this.<>u__1 = awaiter;
GameLauncher.<OnBtnAwaitTaskUsingClick>d__9 stateMachine = this;
this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter<int>, GameLauncher.<OnBtnAwaitTaskUsingClick>d__9>(ref awaiter, ref stateMachine);
return;
}
}
else
{
awaiter = this.<>u__1;
this.<>u__1 = new TaskAwaiter<int>();
this.<>1__state = num2 = -1;
}
this.<>s__3 = awaiter.GetResult();
this.<sumResult>5__2 = this.<>s__3;
Debug.Log((object) string.Format("sumResult:{0}", (object) this.<sumResult>5__2));
Debug.Log((object) string.Format("DateTime.Now.Millisecond:{0}", (object) DateTime.Now.Millisecond));
}
catch (Exception ex)
{
this.<>1__state = -2;
this.<awaitTask>5__1 = (Task<int>) null;
this.<>t__builder.SetException(ex);
return;
}
this.<>1__state = -2;
this.<awaitTask>5__1 = (Task<int>) null;
this.<>t__builder.SetResult();
}

[DebuggerHidden]
void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine)
{
}
}

[CompilerGenerated]
private sealed class <AwaitTaskAction>d__10 : IAsyncStateMachine
{
public int <>1__state;
public AsyncTaskMethodBuilder<int> <>t__builder;
public GameLauncher <>4__this;
private int <sum>5__1;
private int <i>5__2;
private TaskAwaiter <>u__1;

public <AwaitTaskAction>d__10()
{
base..Ector();
}

void IAsyncStateMachine.MoveNext()
{
int num1 = this.<>1__state;
int sum51;
try
{
TaskAwaiter awaiter1;
int num2;
TaskAwaiter awaiter2;
if (num1 != 0)
{
if (num1 != 1)
{
Debug.Log((object) "AwaitTaskAction Start");
Debug.Log((object) string.Format("DateTime.Now.Millisecond:{0}", (object) DateTime.Now.Millisecond));
awaiter1 = Task.Delay(1000).GetAwaiter();
if (!awaiter1.IsCompleted)
{
this.<>1__state = num2 = 0;
this.<>u__1 = awaiter1;
GameLauncher.<AwaitTaskAction>d__10 stateMachine = this;
this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter, GameLauncher.<AwaitTaskAction>d__10>(ref awaiter1, ref stateMachine);
return;
}
}
else
{
awaiter2 = this.<>u__1;
this.<>u__1 = new TaskAwaiter();
this.<>1__state = num2 = -1;
goto label_12;
}
}
else
{
awaiter1 = this.<>u__1;
this.<>u__1 = new TaskAwaiter();
this.<>1__state = num2 = -1;
}
awaiter1.GetResult();
Debug.Log((object) "AwaitTaskAction After Task.Delay(1)");
DateTime now = DateTime.Now;
Debug.Log((object) string.Format("DateTime.Now.Millisecond:{0}", (object) now.Millisecond));
this.<sum>5__1 = 0;
for (this.<i>5__2 = 0; this.<i>5__2 < 1000000000; ++this.<i>5__2)
++this.<sum>5__1;
awaiter2 = Task.Delay(2000).GetAwaiter();
if (!awaiter2.IsCompleted)
{
this.<>1__state = num2 = 1;
this.<>u__1 = awaiter2;
GameLauncher.<AwaitTaskAction>d__10 stateMachine = this;
this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter, GameLauncher.<AwaitTaskAction>d__10>(ref awaiter2, ref stateMachine);
return;
}
label_12:
awaiter2.GetResult();
Debug.Log((object) "AwaitTaskAction After Task.Delay(2)");
now = DateTime.Now;
Debug.Log((object) string.Format("DateTime.Now.Millisecond:{0}", (object) now.Millisecond));
Debug.Log((object) "AwaitTaskAction End");
now = DateTime.Now;
Debug.Log((object) string.Format("DateTime.Now.Millisecond:{0}", (object) now.Millisecond));
sum51 = this.<sum>5__1;
}
catch (Exception ex)
{
this.<>1__state = -2;
this.<>t__builder.SetException(ex);
return;
}
this.<>1__state = -2;
this.<>t__builder.SetResult(sum51);
}

[DebuggerHidden]
void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine)
{
}
}

上面的代码是通过dotPeek(NetReflector也可以是付费软件,使用结束后就用不了了)才反编译出来的,之前试过ILSpy和DnSpy都得不到d__9类定义相关反编译代码。

通过上面的代码可以看到,通过async定义异步方法后,编译器帮我们生成了继承至IAsyncStateMachine的d__9和d__10类,这个两个类正是实现异步的一个关键抽象(个人理解有点像携程里的迭代器封装逻辑代码调用)。

  • 触发OnBtnAwaitTaskUsingClick()方法调用
  • 构建d__9类实例对象状态机,初始化<>t_builder()(AsyncVoidMethodBuilder类型–异步任务构建器,用于保存异步任务结果相关),这里因为OnBtnAwaitTaskUsingClick是void返回类型,所以构建的是AsyncVoieMethodBuilder类对象。初始化状态机<>1__state值为-1
  • 调用stateMachine.<>t__builder.Start<GameLauncher.d__9>(ref stateMachine)触发状态机的MoveNext()的代码执行。
  • 因为<>1__state状态值为-1,所以执行构建5__1(Task类型)的异步AwaitTaskAction执行(默认Task.Run走线程池,所以会运行在不同线程)
  • 这一步AwaitTaskAction异步代码就开始执行了,首先构建d__10类实例对象状态机,初始化<>t__builder(AsyncTaskMethodBuilder类型–异步任务构造器,用于保存异步任务结果相关),这里是因为AwaitTaskAction是Task返回类型,所以构建的是AsyncTaskMethodBuilder类型对象。初始化状态机<>1__state值为-1
  • 调用stateMachine.<>t__builder.Start<GameLauncher.d__10>(ref stateMachine)触发状态机的MoveNext()的代码执行,然后通过d__10.<>t__builder.Task返回AwaitTaskAction的异步任务对象
  • 因为<>1___state状态值为-1,所以首先执行**”AwaitTaskAction Start”**打印,然后获取延迟1秒Task.Delay(1).GetAwaiter()的异步等待完成对象awaiter1
  • 因为Task.Run默认走线程池,所以这里可能回到主线程执行,触发**”AwaitTaskAction has been launched. Main Thread:{Thread.CurrentThread.ManagedThreadId}”**的打印
  • 接着5__1.GetAwaiter()获取AwaitTaskAction的异步等待完成对象(TaskAwaiter类型继承至ICriticalNotifyCompletion),5__1.IsCompleted判定异步没有完成,将<>1__state状态机值设置成0,并保存5__1(AwaitTaskAction的异步任务)到<>u__1
  • 然后调用d__9.<>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref statemachine)触发等待AwaitTaskAction等待异步任务完成(异步任务完成后会自动触发状态机的MoveNext()触发状态机继续执行)
  • 主线程d__9状态机在等待AwaitTaskAction异步任务完成,所以Task.Run在另一个线程运行的d__10状态机又得到机会运行
  • d__10.awaiter1.IsCompleted判定异步没有完成,状态机<>1__state值设置为0d__10的异步等待完成对象<>u__1设置成awaiter1(即Task.Delay(1)的异步任务等待完成对象),然后调用<>t__builder.AwaitUnsafeOnCompleted(ref awaiter1, ref statemachine)触发等待一秒等待异步任务完成(异步任务完成后会自动触发状态机的MoveNext()触发状态机继续执行)
  • 等待一秒过后,Task.Delay(1)异步任务完成,触发__d__10状态机的MoveNext执行,此时状态机<>1__state值为0,将Task.Delay(1).GetAwaiter()对象保存到awaiter1,<>u__1构建一个新的TaskAwaiter()对象,<>1__state状态机值设置成-1,然后调用awaiter1.GetResult()结束异步任务完成等待(因为是Task.Delay(1)所以没有结果需要保存),此时打印**”AwaitTaskAction After Task.Delay(1)**
  • 然后开始计算for循环累加的代码,初始化5__1为0,执行5__1的循环累加
  • 累加执行完成后执行Task.Delay(2),此时保存Task.Delay(2).GetAwaiter()的异步等待完成对象到awaiter2,awaiter2.IsCompleted判定异步没有完成,将<>1__state状态机值设置成1,并保存Task.Delay(2).GetAwaiter()到<>u__1,然后调用<>t__builder.AwaitUnsafeOnCompleted(ref awaiter2, ref statemachine)触发等待两秒等待异步任务完成(异步任务完成后会自动触发状态机的MoveNext()触发状态机继续执行)
  • 等待两秒过后,Task.Delay(2)异步任务完成,触发__d__10状态机的MoveNext执行,此时状态机<>1__state值为1,执行awaiter2保存<>u__1(Task.Delay(2).GetAwaiter()的异步任务等待完成对象),同时构建一个新的TaskAwaiter()赋值给<>u__1,并设置**<>1__stat状态机值到-1**
  • 然后直接跳转到代码label_12标签片段,此时Task.Delay(2)异步任务已经完成,调用awaiter.GetResult()直接触发异步任务完成等待
  • 紧接着打印**”AwaitTaskAction After Task.Delay(2)”并将累加结果5__1保存到sum51成员变量,然后将状态机<>1__state值设置成-2**同时将累加结果sum51通过<>t__builder(**AsyncTaskMethodBuilder类型)调用SetResult(sum51)将AwaitTaskAction()方法调用的返回结果设置到异步任务构建器里
  • 上面一套流程下来,AwaitTaskAction的异步代码就算是执行完成了(即d__9状态机里Task.Run(new Func<Task>((object) this.<>4__this, __methodptr(AwaitTaskAction)))的异步方法执行完成了),d__9.<>t__builder.AwaitUnsafeOnCompleted()监听到异步任务完成,触发d__9的MoveNext()执行
  • 此时d__9.<>1__state状态机值为0,执行awaiter保存<>u__1(AwaitTaskAction的异步任务等待完成对象),新创建一个TaskAwaiter异步任务等待完成对象并设置给<>u__1,同时<>1__state状态机值设置成-1
  • 调用awaiter.GetResult()获取AwaitTaskAction异步任务结果并存储到d__9.<>s__3和d__9.5__2
  • 打印**”sumResult:{sumResult}”**,将<>1__state状态机值设置成-2,5__1存储的AwaitTaskAction异步任务置空
  • 最后通过d__9.<>t__builder:SetResult()标记OnBtnAwaitTaskUsingClick的异步任务完成和设置结果(因为OnBtnAwaitTaskUsingClick是void返回所以无需设置结果值)

就这样我们所有的异步线性流程都通过代码生成状态机运行的方式,变成了一个一个的状态按顺序执行。

可以看到异步线性流程和核心由两部分组成:

  1. Task异步运行机制
  2. 异步状态机代码生成

On the C# side of things, the compiler transforms your code into a state machine that keeps track of things like yielding execution when an await is reached and resuming execution when a background job has finished.

Task异步取消

Task异步的取消是通过Cancellation Token来完成的。

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
/// <summary>
/// 响应开启等待取消Task按钮点击
/// </summary>
private async void OnBtnWaitCancelTaskUsingClick()
{
mTaskCancelTokenSource = new CancellationTokenSource();
var taskToken = mTaskCancelTokenSource.Token;
var awaitTask = Task.Run(() =>
{
return AwaitCancelTaskAction(taskToken);
}, taskToken);
Debug.Log($"AwaitCancelTaskAction has been launched. Main Thread:{Thread.CurrentThread.ManagedThreadId}");
try
{
var sumResult = await awaitTask;
Debug.Log($"AwaitCancelTaskAction sumResult:{sumResult}");
Debug.Log($"AwaitCancelTaskAction DateTime.Now.Millisecond:{DateTime.Now.Millisecond}");
}
catch (OperationCanceledException e)
{
Debug.Log(e);
Debug.Log($"e.CancelLlationToken.Equals(taskCancelToken):{e.CancellationToken.Equals(taskToken}");
}
finally
{
mTaskCancelTokenSource.Dispose();
}
}

/// <summary>
/// 等待取消的Task方法
/// </summary>
/// <param name="taskToken"></param>
/// <returns></returns>
private async Task<int> AwaitCancelTaskAction(CancellationToken taskToken)
{
// 检测异步Task是否已经取消
taskToken.ThrowIfCancellationRequested();
Debug.Log($"AwaitCancelTaskAction Start");
Debug.Log($"DateTime.Now.Millisecond:{DateTime.Now.Millisecond} Thread Id:{Thread.CurrentThread.ManagedThreadId}");
await Task.Delay(1000);
// 检测异步Task是否已经取消
taskToken.ThrowIfCancellationRequested();
Debug.Log($"AwaitCancelTaskAction After Task.Delay(1)");
Debug.Log($"DateTime.Now.Millisecond:{DateTime.Now.Millisecond}");
int sum = 0;
for(int i = 0; i < 10000000; i++)
{
sum++;
}
await Task.Delay(2000);
// 检测异步Task是否已经取消
taskToken.ThrowIfCancellationRequested();
Debug.Log($"AwaitCancelTaskAction After Task.Delay(2)");
Debug.Log($"DateTime.Now.Millisecond:{DateTime.Now.Millisecond}");
Debug.Log($"AwaitCancelTaskAction End");
return sum;
}

/// <summary>
/// 响应Task取消按钮点击
/// </summary>
private void OnBtnCancelTaskUsingClick()
{
mTaskCancelTokenSource?.Cancel();
Debug.Log($"取消异步Task");
}

上面的代码可以看到,我们在开启一个需要支持取消的异步Task时需要以下步骤:

  1. 申请创建了一个CancellationTokenSource对象,这个对象会包含我们异步Task取消所需的token。
  2. 将CancellationTokenSource.token传递给需要异步执行的Task(多层调用要一直传递下去,确保Task正确异步取消)
  3. 在需要异步打断的Task里判定token是否已经取消(token.ThrowIfCancellationRequested())
  4. try catch异步await,确保释放token source(CancellationTokenSource.Dispose())

在上面的使用中可以看到,Task异步是多线程的,通过Task.Run()我们启用了一个新的线程执行任务,所以打印出的Thread Id和Main Thread Id不一样。

为了确保Task取消后异步逻辑能正确停止,我们在异步逻辑里关键地方都在判定taskToken.ThrowIfCancellationRequested()通知Task进入取消状态。

Task总结

以下总结来源:深入解析C#异步编程:await 关键字背后的实现原理

深入理解 AsyncTaskMethodBuilder

AsyncTaskMethodBuilder 是一个辅助类,用于构建和管理异步方法的任务。它提供了以下方法:

  • Create:创建一个新的 AsyncTaskMethodBuilder 实例。
  • Start:开始执行异步方法,调用状态机的 MoveNext 方法。
  • AwaitUnsafeOnCompleted:注册回调函数,当任务完成时触发回调。
  • SetResult:设置任务的结果。
  • SetException:设置任务的异常。
异步方法的生命周期
  1. 初始化:创建状态机实例,初始化状态和任务构建器。
  2. 开始执行:调用 Start 方法开始执行异步方法。
  3. 执行方法体:在 MoveNext 方法中,根据当前状态执行相应的代码。
  4. 遇到 await:检查任务是否完成,如果未完成则注册回调并暂停方法执行。
  5. 任务完成:回调被触发,重新调用 MoveNext 方法,恢复异步方法的执行。
  6. 方法完成:所有异步操作完成,设置任务的结果或异常。
总结
  1. 异步方法的基本概念asyncawait 关键字用于编写异步代码。
  2. 状态机的生成:编译器为每个异步方法生成一个状态机,包含所有局部变量和状态信息。
  3. MoveNext 方法的执行MoveNext 方法是状态机的核心,负责管理和执行异步操作。
  4. 回调函数的注册和触发:
    • 当遇到 await 关键字时,编译器会生成代码来检查任务是否已经完成。
    • 如果任务未完成,注册回调并暂停方法执行。
    • 当任务完成时,回调函数会被触发,重新调用状态机的 MoveNext 方法,从而恢复异步方法的执行。
  5. AwaitUnsafeOnCompleted 方法的作用:在任务完成时注册一个回调函数,回调函数会在任务完成后被触发,从而恢复异步方法的执行。

使用体验

通过上面的学习使用,这里个人讲讲个人Task的使用心得。

好处:

  1. 通过Task异步我们能将异步逻辑跟同步逻辑一样方便的线性流程编写,不再需要编写回调嵌套(回调地狱)的代码。

不方便处:

  1. Task异步需要定义async关键字,导致所有异步方法都需要加上async关键字(async传染)
  2. Task异步取消时,使用者在异步逻辑里需要关心 taskToken是否已经取消(taskToken.ThrowIfCancellationRequested()),这样代码写起来还是比较麻烦
  3. 开启一个异步Task需要通过Task.Run或者Task.Factory.StartNew方法,这样导致我们嵌套调用异步方法传递token时被迫用上闭包这种方式
  4. Task异步是多线程,需要考虑数据多线程访问问题,对开发者多线程相关知识要求更高

自定义Task

通过上面的学习我们了解了Task和async,await关键词是如何组合起来实现异步等待的。但Task默认相关都是带线程池的,如果我们想实现一个单线程的异步Task(好比UniTask)应该如何实现了?

核心需求如下:

  1. 支持async和await的自定义Task
  2. 类似UniTask的单线程Task
  3. 支持常规的单帧等待,指定时长等待Task
  4. 支持Unity不同Update模式的更新驱动选择
  5. 支持异步任务取消
  6. 支持带返回T类型数据和void返回类型的异步任务

以下实现思路参考来源:

每个人都能写UniTask!!!自定义一个TaskLike并完成一个简单的Delay延时教程

在开始实现自定义Task之前,我们首先先设计好类图,明确各个类的功能。

  1. CustomVoidTask

    自定义void返回类型的Task

  2. CustomAsyncVoidMethodBuilder

    用于生成返回void类型的Task结构体的自定义异步任务构造器(struct),通过在对应Task定义添加[AsyncMethodBuilder(typeof(CustomAsyncVoidMethodBuilder))]标签标记指定异步任务构造器

  3. CustomTaskAwaiter

    抽象自定义void返回类型的异步任务等待器

  4. ICustomTaskSource

    抽象一层任务数据,通过依赖反转实现上层DIY的自定义任务void类型(比如CustomDelayTask)不影响整体自定义Task框架。

  5. **CustomVoidTaskCompletionSource **

    自定义返回void类型的任务完成数据抽象(CustomAsyncVoidMethodBuilder会构建),这个类很重要,是我们包装每一个async函数都要生成的一个类,它用来记录整个async函数的完成情况(注意是整个,这个很重要),完成后的回调,以及异常、取消等实现。

  6. CustomTaskLoopManager

    自定义任务循环管理单例类,负责需要注入Update驱动的统一管理(比如实现CustomTask.Delay()和CustomTask.Yield()等效果)

  7. CustomTaskLoopType

自定义任务循环枚举,用于支持类似Unity里不同Update的定义

  1. CustomTaskStatus

​ 自定义异步任务状态枚举

  1. CancellationTokenSourceCancellationToken

采用原生定义的结构辅助设计异步任务取消,ICustomTaskSource子类通过定义SetCanceled方法实现自定义相关的异步任务取消逻辑,CancellationTokenSource和CancellationToken主要用于解决异步嵌套的一系列义务任务打断判定和回调注入

  1. CustomTask

    自定义T返回类型的Task

  2. CustomAsyncMethodBuilder

    用于生成返回T类型的Task结构体的自定义异步任务构造器(struct),通过在对应Task定义添加[AsyncMethodBuilder(typeof(CustomAsyncMethodBuilder))]标签标记指定异步任务构造器

  3. CustomTaskAwaiter

    抽象自定义T返回类型的异步任务等待器

  4. ICustomTaskSource

    抽象一层任务数据,通过依赖反转实现上层DIY的自定义任务返回T类型不影响整体自定义Task框架。

  5. CustomTaskCompletionSource

    自定义返回T类型的任务完成数据抽象(CustomAsyncMethodBuilder会构建),这个类很重要,是我们包装每一个async函数都要生成的一个类,它用来记录整个async函数的完成情况(注意是整个,这个很重要),完成后的回调,以及异常、取消等实现。

  6. CustomDelayTask

    自定义延迟指定毫秒Task

  7. CustomYieldTask

    自定义延迟指定帧数Task

进阶(UniTask)

了解Task异步的基础使用和设计,接下来让我们看看UniTask是如何实现Unity内更优的异步框架设计的。

官网介绍:

UniTask的特点

  • 为 Unity 提供有效的无GC async/await集成。
  • 基于Struct UniTask<T> 的自定义 AsyncMethodBuilder,实现零GC,使所有Unity的异步操作和协程可以await
  • 基于PlayerLoop的Task( UniTask.YieldUniTask.DelayUniTask.DelayFrame 等)这使得能够替换所有协程操作
  • MonoBehaviour 消息事件和 uGUI 事件为可使用Await/AsyncEnumerable
  • 完全在 Unity 的 PlayerLoop 上运行,因此不使用线程,可在 WebGL、wasm 等平台上运行。
  • 异步 LINQ,具有Channel和 AsyncReactiveProperty
  • 防止内存泄漏的 TaskTracker (Task追踪器)窗口
  • 与Task/ValueTask/IValueTaskSource 的行为高度兼容

上面提到UniTask没有使用多线程,这一点对于Unity单线程开发理念更加适合。

更多学习使用待添加……(TODO)

重点知识

  1. Unity子线程内不可访问游戏对象或者组件以及相关方法,只用于处理数据或者逻辑,任何要与主线程发生关系的地方都必须进行回调和上下文切换。
  2. await和async关键词并不一定触发多线程,是否多线程是与使用的具体方式而定(比如Task.Run)

Reference

Asynchronous programming

Task-based Asynchronous Pattern(TAP)

深入解析C#异步编程:await 关键字背后的实现原理

每个人都能写UniTask!!!自定义一个TaskLike并完成一个简单的Delay延时教程

Github

UniTask

前言

游戏开发过程中为了提示玩家有可操作数据,往往会采用显示红点的方式,红点会散布到游戏的各个角落,如果项目初期没有一个好的红点系统,后期维护开发中往往会有数不清的红点bug以及不必要的红点刷新开销。本章节真是为了实现一套高效且高度可扩展和可维护的红点系统而编写的。

红点系统

红点系统需求

在开始设计和编写我们需要的红点系统前,让我们先理清下红点的需求:

  1. 单个红点可能受多个游戏逻辑因素影响
  2. 内层红点可以影响外层红点可以不影响外层红点
  3. 红点显示逻辑会受功能解锁和游戏数据流程影响
  4. 红点样式多种多样(e.g. 1. 纯红点 2. 纯数字红点 3. 新红点 4. 混合红点等)
  5. 红点结果计算的数据来源可以是客户端计算红点也可以是服务器已经算好的红点结论(有时候为了优化数据通信量,有些功能的详情会按需请求导致客户端无法直接计算红点,采取后端直接通知红点的方式)
  6. 红点分静态红点(界面上固定存在的)和动态红点(列表里数量繁多的)
  7. 数据变化会触发红点频繁逻辑运算(有时候还会触发重复运算)导致GC和时间占用
  8. 红点影响因素比较多,查询的时候缺乏可视化高效的查询手段

红点系统设计

针对上面的红点需求,我通过以下设计来一一解决:

  1. 采用前缀树数据结构,从红点命名上解决红点父子定义关联问题
  2. 红点运算单元采用最小单元化定义,采用组合定义方式组装红点影响因素,从而实现高度自由的红点运算逻辑组装
  3. 红点运算单元作为红点影响显示的最小单元,每一个都会对应逻辑层面的一个计算代码,从而实现和逻辑层关联实现自定义解锁和计算方式
  4. 红点运算结果按红点运算单元为单位,采用标脏加延迟计算的方式避免重复运算和结果缓存
  5. 红点运算单元支持多种显示类型定义(e.g. 1. 纯红点 2. 纯数字红点 3. 新红点 4. 混合红点等),红点最终显示类型由所有影响他的红点运算单元计算结果组合而成(e.g. 红点运算单元1(新红点类型)+红点运算单元2(数字红点类型)=新红点类型)
  6. 除了滚动列表里数量过多的红点采用界面上自行计算的方式,其他红点全部采用静态红点预定义的方式,全部提前定义好红点名以及红点运算单元组成和父子关系等数据
  7. 编写自定义EditorWindow实现红点数据全面可视化提升红点系统可维护性

前缀树

在真正实战编写红点系统之前,让我们先了解实现一版前缀树,后续会用到红点系统里解决红点父子关联问题。

trie,又称前缀树字典树,是一种有序树,用于保存关联数组,其中的键通常是字符串。与二叉查找树不同,键不是直接保存在节点中,而是由节点在树中的位置决定。一个节点的所有子孙都有相同的前缀,也就是这个节点对应的字符串,而根节点对应空字符串。

前缀树结构

从上面可以看出前缀树也是树机构,但不是通常说的二叉树。

前缀树最典型的应用就是输入法单次推断,比如我们输入周星两个字,输入法会根据用户输入的周星两个字去查询词库,看有哪些匹配的词语进行人性化引导提示显示。而这个查询词库匹配的过程就要求很高的查询效率,而前缀树正是完美解决了这一问题。前缀树通过先搜索周节点,然后查找到周节点后继续在周节点下搜寻星字节点,查到星字节点后,以星字节点继续往下搜索匹配出所有符合的单词进行显示,从而通过O(N)的方式实现了快速的单词匹配查询。

前缀树实战

Trie.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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using UnityEngine;

/// <summary>
/// 前缀树
/// </summary>
public class Trie
{
/// <summary>
/// 单词分隔符
/// </summary>
public char Separator
{
get;
private set;
}

/// <summary>
/// 单词数量
/// </summary>
public int WorldCount
{
get;
private set;
}

/// <summary>
/// 树深度
/// </summary>
public int TrieDeepth
{
get;
private set;
}

/// <summary>
/// 根节点
/// </summary>
public TrieNode RootNode
{
get;
private set;
}

/// <summary>
/// 单词列表(用于缓存分割结果,优化单个单词判定时重复分割问题)
/// </summary>
private List<string> mWordList;

/// <summary>
/// 构造函数
/// </summary>
/// <param name="separator"></param>
public Trie(char separator = '|')
{
Separator = separator;
WorldCount = 0;
TrieDeepth = 0;
RootNode = ObjectPool.Singleton.pop<TrieNode>();
RootNode.Init("Root", null, this, 0, false);
mWordList = new List<string>();
}

/// <summary>
/// 添加单词
/// </summary>
/// <param name="word"></param>
public void AddWord(string word)
{
mWordList.Clear();
var words = word.Split(Separator);
mWordList.AddRange(words);
var length = mWordList.Count;
var node = RootNode;
for (int i = 0; i < length; i++)
{
var spliteWord = mWordList[i];
var isLast = i == (length - 1);
if (!node.ContainWord(spliteWord))
{
node = node.AddChildNode(spliteWord, isLast);
}
else
{
node = node.GetChildNode(spliteWord);
if(isLast)
{
Debug.Log($"添加重复单词:{word}");
}
}
}
}

/// <summary>
/// 移除指定单词
/// Note:
/// 仅当指定单词存在时才能移除成功
/// </summary>
/// <param name="word"></param>
/// <returns></returns>
public bool RemoveWord(string word)
{
if(string.IsNullOrEmpty(word))
{
Debug.LogError($"不允许移除空单词!");
return false;
}
var wordNode = GetWordNode(word);
if(wordNode == null)
{
Debug.LogError($"找不到单词:{word}的节点信息,移除单词失败!");
return false;
}
if(wordNode.IsRoot)
{
Debug.LogError($"不允许删除根节点!");
return false;
}
// 从最里层节点开始反向判定更新和删除
if(!wordNode.IsTail)
{
Debug.LogError($"单词:{word}的节点不是单词节点,移除单词失败!");
return false;
}
// 删除的节点是叶子节点时要删除节点并往上递归更新节点数据
// 反之只更新标记为非单词节点即可结束
if(wordNode.ChildCount > 0)
{
wordNode.IsTail = false;
return true;
}
wordNode.RemoveFromParent();
// 网上遍历更新节点信息
var node = wordNode.Parent;
while(node != null && !node.IsRoot)
{
// 没有子节点且不是单词节点则直接删除
if(node.ChildCount == 0 && !node.IsTail)
{
node.RemoveFromParent();
}
node = node.Parent;
// 有子节点则停止往上更新
if(node.ChildCount > 0)
{
break;
}
}
return true;
}

/// <summary>
/// 获取指定字符串的单词节点
/// Note:
/// 只有满足每一层且最后一层是单词的节点才算有效单词节点
/// </summary>
/// <param name="word"></param>
/// <returns></returns>
public TrieNode GetWordNode(string word)
{
if (string.IsNullOrEmpty(word))
{
Debug.LogError($"无法获取空单词的单次节点!");
return null;
}
// 从最里层节点开始反向判定更新和删除
var wordArray = word.Split(Separator);
var node = RootNode;
foreach(var spliteWord in wordArray)
{
var childNode = node.GetChildNode(spliteWord);
if (childNode != null)
{
node = childNode;
}
else
{
break;
}
}
if(node == null || !node.IsTail)
{
Debug.Log($"找不到单词:{word}的单词节点!");
return null;
}
return node;
}

/// <summary>
/// 有按指定单词开头的词语
/// </summary>
/// <param name="word"></param>
/// <returns></returns>
public bool StartWith(string word)
{
if (string.IsNullOrEmpty(word))
{
return false;
}
mWordList.Clear();
var wordArray = word.Split(Separator);
mWordList.AddRange(wordArray);
return FindWord(RootNode, mWordList);
}

/// <summary>
/// 查找单词
/// </summary>
/// <param name="trieNode"></param>
/// <param name="wordList"></param>
/// <returns></returns>
private bool FindWord(TrieNode trieNode, List<string> wordList)
{
if (wordList.Count == 0)
{
return true;
}
var firstWord = wordList[0];
if (!trieNode.ContainWord(firstWord))
{
return false;
}
var childNode = trieNode.GetChildNode(firstWord);
wordList.RemoveAt(0);
return FindWord(childNode, wordList);
}

/// <summary>
/// 单词是否存在
/// </summary>
/// <param name="word"></param>
/// <returns></returns>
public bool ContainWord(string word)
{
if(string.IsNullOrEmpty(word))
{
return false;
}
mWordList.Clear();
var wordArray = word.Split(Separator);
mWordList.AddRange(wordArray);
return MatchWord(RootNode, mWordList);
}

/// <summary>
/// 匹配单词(单词必须完美匹配)
/// </summary>
/// <param name="trieNode"></param>
/// <param name="wordList"></param>
/// <returns></returns>
private bool MatchWord(TrieNode trieNode, List<string> wordList)
{
if (wordList.Count == 0)
{
return trieNode.IsTail;
}
var firstWord = wordList[0];
if (!trieNode.ContainWord(firstWord))
{
return false;
}
var childNode = trieNode.GetChildNode(firstWord);
wordList.RemoveAt(0);
return MatchWord(childNode, wordList);
}

/// <summary>
/// 获取所有单词列表
/// </summary>
/// <returns></returns>
public List<string> GetWordList()
{
return GetNodeWorldList(RootNode, string.Empty);
}

/// <summary>
/// 获取节点单词列表
/// </summary>
/// <param name="trieNode"></param>
/// <param name="preFix"></param>
/// <returns></returns>
private List<string> GetNodeWorldList(TrieNode trieNode, string preFix)
{
var wordList = new List<string>();
foreach (var childNodeKey in trieNode.ChildNodesMap.Keys)
{
var childNode = trieNode.ChildNodesMap[childNodeKey];
string word;
if (trieNode.IsRoot)
{
word = $"{preFix}{childNodeKey}";
}
else
{
word = $"{preFix}{Separator}{childNodeKey}";
}
if (childNode.IsTail)
{
wordList.Add(word);
}
if (childNode.ChildNodesMap.Count > 0)
{
var childNodeWorldList = GetNodeWorldList(childNode, word);
wordList.AddRange(childNodeWorldList);
}
}
return wordList;
}

/// <summary>
/// 打印树形节点
/// </summary>
public void PrintTreeNodes()
{
PrintNodes(RootNode, 1);
}

/// <summary>
/// 打印节点
/// </summary>
/// <param name="node"></param>
/// <param name="depth"></param>
private void PrintNodes(TrieNode node, int depth = 1)
{
var count = 1;
foreach (var childeNode in node.ChildNodesMap)
{
Console.Write($"{childeNode.Key}({depth}-{count})");
count++;
}
Console.WriteLine();
foreach (var childeNode in node.ChildNodesMap)
{
PrintNodes(childeNode.Value, depth + 1);
}
}
}

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

/// <summary>
/// TrieNode.cs
/// 前缀树节点类
/// </summary>
public class TrieNode : IRecycle
{
/// <summary>
/// 节点字符串
/// </summary>
public string NodeValue
{
get;
private set;
}

/// <summary>
/// 父节点
/// </summary>
public TrieNode Parent
{
get;
private set;
}

/// <summary>
/// 所属前缀树
/// </summary>
public Trie OwnerTree
{
get;
private set;
}

/// <summary>
/// 节点深度(根节点为0)
/// </summary>
public int Depth
{
get;
private set;
}

/// <summary>
/// 是否是单词节点
/// </summary>
public bool IsTail
{
get;
set;
}

/// <summary>
/// 是否是根节点
/// </summary>
public bool IsRoot
{
get
{
return Parent == null;
}
}

/// <summary>
/// 子节点映射Map<节点字符串, 节点对象>
/// </summary>
public Dictionary<string, TrieNode> ChildNodesMap
{
get;
private set;
}

/// <summary>
/// 子节点数量
/// </summary>
public int ChildCount
{
get
{
return ChildNodesMap.Count;
}
}

public TrieNode()
{
ChildNodesMap = new Dictionary<string, TrieNode>();
}

public void OnCreate()
{
NodeValue = null;
Parent = null;
OwnerTree = null;
Depth = 0;
IsTail = false;
ChildNodesMap.Clear();
}

/// <summary>
/// 初始化数据
/// </summary>
/// <param name="value">字符串</param>
/// <param name="parent">父节点</param>
/// <param name="ownerTree">所属前缀树</param>
/// <param name="depth">节点深度</param>
/// <param name="isTail">是否是单词节点</param>
public void Init(string value, TrieNode parent, Trie ownerTree, int depth, bool isTail = false)
{
NodeValue = value;
Parent = parent;
OwnerTree = ownerTree;
Depth = depth;
IsTail = isTail;
}

public void OnDispose()
{
NodeValue = null;
Parent = null;
OwnerTree = null;
Depth = 0;
IsTail = false;
ChildNodesMap.Clear();
}

/// <summary>
/// 添加子节点
/// </summary>
/// <param name="nodeWord"></param>
/// <param name="isTail"></param>
/// <returns></returns>
public TrieNode AddChildNode(string nodeWord, bool isTail)
{
TrieNode node;
if (ChildNodesMap.TryGetValue(nodeWord, out node))
{
Debug.Log($"节点字符串:{NodeValue}已存在字符串:{nodeWord}的子节点,不重复添加子节点!");
return node;
}
node = ObjectPool.Singleton.pop<TrieNode>();
node.Init(nodeWord, this, OwnerTree, Depth + 1, isTail);
ChildNodesMap.Add(nodeWord, node);
return node;
}

/// <summary>
/// 移除指定子节点
/// </summary>
/// <param name="nodeWord"></param>
/// <returns></returns>
public bool RemoveChildNodeByWord(string nodeWord)
{
var childNode = GetChildNode(nodeWord);
return RemoveChildNode(childNode);
}

/// <summary>
/// 移除指定子节点
/// </summary>
/// <param name="childNode"></param>
/// <returns></returns>
public bool RemoveChildNode(TrieNode childNode)
{
if(childNode == null)
{
Debug.LogError($"无法移除空节点!");
return false;
}
var realChildNode = GetChildNode(childNode.NodeValue);
if(realChildNode != childNode)
{
Debug.LogError($"移除的子节点单词:{childNode.NodeValue}对象不是同一个,移除子节点失败!");
return false;
}
ChildNodesMap.Remove(childNode.NodeValue);
ObjectPool.Singleton.push<TrieNode>(childNode);
return true;
}

/// <summary>
/// 当前节点从父节点移除
/// </summary>
/// <returns></returns>
public bool RemoveFromParent()
{
if(IsRoot)
{
Debug.LogError($"当前节点是根节点,不允许从父节点移除,从父节点移除当前节点失败!");
return false;
}
return Parent.RemoveChildNode(this);
}

/// <summary>
/// 获取指定字符串的子节点
/// </summary>
/// <param name=""></param>
/// <param name=""></param>
/// <returns></returns>
public TrieNode GetChildNode(string nodeWord)
{
TrieNode trieNode;
if (!ChildNodesMap.TryGetValue(nodeWord, out trieNode))
{
Debug.Log($"节点字符串:{NodeValue}找不到子节点字符串:{nodeWord},获取子节点失败!");
return null;
}
return trieNode;
}

/// <summary>
/// 是否包含指定字符串的子节点
/// </summary>
/// <param name=""></param>
/// <param name=""></param>
/// <returns></returns>
public bool ContainWord(string nodeWord)
{
return ChildNodesMap.ContainsKey(nodeWord);
}

/// <summary>
/// 获取当前节点构成的单词
/// Note:
/// 不管当前节点是否是单词节点,都返回从当前节点回溯到根节点拼接的单词
/// 若当前节点为根节点,则返回根节点的字符串(默认为"Root")
/// </summary>
/// <returns></returns>
public string GetFullWord()
{
var trieNodeWord = NodeValue;
var node = Parent;
while(node != null && !node.IsRoot)
{
trieNodeWord = $"{node.NodeValue}{OwnerTree.Separator}{trieNodeWord}";
node = node.Parent;
}
return trieNodeWord;
}
}

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

/// <summary>
/// TrieEditorWindow.cs
/// 前缀树窗口
/// </summary>
public class TrieEditorWindow : EditorWindow
{
/// <summary>
/// 居中Button GUI Style
/// </summary>
private GUIStyle mButtonMidStyle;

/// <summary>
/// 前缀树
/// </summary>
private Trie mTrie;

/// <summary>
/// 当前滚动位置
/// </summary>
private Vector2 mCurrentScrollPos;

/// <summary>
/// 输入单词
/// </summary>
private string mInputWord;

/// <summary>
/// 节点展开Map<节点单词全名, 是否展开>
/// </summary>
private Dictionary<string, bool> mTrieNodeUnfoldMap = new Dictionary<string, bool>();

/// <summary>
/// 前缀树单词列表
/// </summary>
private List<string> mTrieWordList;

[MenuItem("Tools/前缀树测试窗口")]
static void Init()
{
TrieEditorWindow window = (TrieEditorWindow)EditorWindow.GetWindow(typeof(TrieEditorWindow), false, "前缀树测试窗口");
window.Show();
}

void OnGUI()
{
InitGUIStyle();
InitData();
mCurrentScrollPos = EditorGUILayout.BeginScrollView(mCurrentScrollPos);
EditorGUILayout.BeginVertical();
DisplayTrieOperationArea();
DisplayTrieContentArea();
DisplayTrieWordsArea();
EditorGUILayout.EndVertical();
EditorGUILayout.EndScrollView();
}

/// <summary>
/// 初始化GUIStyle
/// </summary>
private void InitGUIStyle()
{
if(mButtonMidStyle == null)
{
mButtonMidStyle = new GUIStyle("ButtonMid");
}
}

/// <summary>
/// 初始化数据
/// </summary>
private void InitData()
{
if (mTrie == null)
{
mTrie = new Trie();
mTrieWordList = null;
}
}

/// <summary>
/// 更新前缀树单词列表
/// </summary>
private void UpdateTrieWordList()
{
mTrieWordList = mTrie.GetWordList();
}

/// <summary>
/// 显示前缀树操作区域
/// </summary>
private void DisplayTrieOperationArea()
{
EditorGUILayout.BeginHorizontal("box");
EditorGUILayout.LabelField("单词:", GUILayout.Width(40f), GUILayout.Height(20f));
mInputWord = EditorGUILayout.TextField(mInputWord, GUILayout.ExpandWidth(true), GUILayout.Height(20f));
if(GUILayout.Button("添加", GUILayout.Width(120f), GUILayout.Height(20f)))
{
if (string.IsNullOrEmpty(mInputWord))
{
Debug.LogError($"不能允许添加空单词!");
}
else
{
mTrie.AddWord(mInputWord);
UpdateTrieWordList();
}
}
if (GUILayout.Button("删除", GUILayout.Width(120f), GUILayout.Height(20f)))
{
if(string.IsNullOrEmpty(mInputWord))
{
Debug.LogError($"不能允许删除空单词!");
}
else
{
mTrie.RemoveWord(mInputWord);
}
}
EditorGUILayout.EndHorizontal();
}

/// <summary>
/// 绘制前缀树内容
/// </summary>
private void DisplayTrieContentArea()
{
EditorGUILayout.BeginVertical("box");
EditorGUILayout.LabelField("前缀树节点信息", mButtonMidStyle, GUILayout.ExpandWidth(true), GUILayout.Height(20f));
DisplayTrieNode(mTrie.RootNode);
EditorGUILayout.EndVertical();
}

/// <summary>
/// 显示一个节点
/// </summary>
/// <param name="trieNode"></param>
private void DisplayTrieNode(TrieNode trieNode)
{
var nodeFullWord = trieNode.GetFullWord();
if(!mTrieNodeUnfoldMap.ContainsKey(nodeFullWord))
{
mTrieNodeUnfoldMap.Add(nodeFullWord, true);
}
EditorGUILayout.BeginHorizontal("box");
GUILayout.Space(trieNode.Depth * 20);
var displayName = $"{trieNode.NodeValue}({trieNode.Depth})";
if (trieNode.ChildCount > 0)
{
mTrieNodeUnfoldMap[nodeFullWord] = EditorGUILayout.Foldout(mTrieNodeUnfoldMap[nodeFullWord], displayName);
}
else
{
EditorGUILayout.LabelField(displayName);
}
EditorGUILayout.EndHorizontal();
if(mTrieNodeUnfoldMap[nodeFullWord] && trieNode.ChildCount > 0)
{
var childNodeValueList = trieNode.ChildNodesMap.Keys.ToList();
foreach(var childNodeValue in childNodeValueList)
{
var childNode = trieNode.GetChildNode(childNodeValue);
DisplayTrieNode(childNode);
}
}
}

/// <summary>
/// 显示前缀树单词区域
/// </summary>
private void DisplayTrieWordsArea()
{
EditorGUILayout.BeginVertical("box");
EditorGUILayout.LabelField("前缀树单词信息", mButtonMidStyle, GUILayout.ExpandWidth(true), GUILayout.Height(20f));
if(mTrieWordList != null)
{
foreach (var word in mTrieWordList)
{
EditorGUILayout.LabelField(word, GUILayout.ExpandWidth(true), GUILayout.Height(20f));
}
}
EditorGUILayout.EndVertical();
}
}

TrieEditorPreview

从上面可以看到我们成功通过字符串+|分割的方式,分析出了我们添加的单词的在前缀树中的关联关系。

红点系统实战

红点系统类说明

RedDotName.cs – 红点名定义(通过前缀树表达父子关系,所有静态红点都一开始定义在这里)

RedDotInfo.cs – 红点信息类(包含红点运算单元组成信息)

RedDotUnit.cs – 红点运算单元枚举定义(所有静态红点需要参与运算的最小单元都定义在这里)

RedDotUnitInfo.cs – 红点运算单元类(只是当做刷新机制的无需传递计算回调)

RedDotType.cs – 红点类型(用于支持上层各类复杂的红点显示方式 e.g. 纯红点,纯数字,新红点等)

RedDotModel.cs – 红点数据层(所有的红点名信息和红点运算单元信息全部在这一层初始化)

RedDotManager.cs – 红点单例管理类(提供统一的红点管理,红点运算单元计算结果缓存,红点绑定回调等流程)

RedDotUtilities.cs – 红点辅助类(一些通用方法还有逻辑层的红点运算方法定义在这里)

GameModel.cs – 逻辑数据层存储模拟

RedDotEditorWindow.cs – 红点系统可视化窗口(方便快速可视化查看红点运行状态和相关信息)

RedDotStyles.cs – 红点Editor显示Style定义

Trie.cs – 前缀树(用于红点名通过字符串的形式表达出层级关系)

TrieNode.cs – 前缀树节点

实战

由于代码部分比较多,这里就不放源代码了,直接看实战效果图,源码可以在最后的Github链接找到。

初始化后的红点前缀树状态:

RedDotTriePreiview

点击标记功能1新按钮后:

RedDotTriePreiviewAfterMarkFunc1New

点击菜单->背包->点击增加1个当前页签的新道具,切换页签并点击操作数据增加:

BackpackUIOperation

背包操作完后,主界面状态:

MainUIAfterBackpackOperation

背包操作完后,红点可视化前缀树:

RedDotTrieAfterBackpackUIOperation

背包增加操作后,MAIN_UI_MENU红点名的红点可视化详情:

RedDotDetailAfterBackpackOperation

所有红点运算单元详情:

AllRedDotUnitInfoPreview0

通过菜单->背包->点击减少1个当前页签的新道具,切换页签点击并操作数据减少:

BackpackUIOperationAfterReduce

背包减少操作后,红点可视化前缀树:

RedDotTriePreviewAfterBackpackReduce

从上面的测试可以看到,我们通过定义红点名,红点运算单元相关数据,成功的分析出了红点层级关系(利用前缀树)以及红点名与红点运算单元的组合关系。

通过编写RedDotEditorWindow成功将红点数据详情可视化的显示在了调试窗口上,通过调试窗口我们可以快速的查看所有红点名和红点运算单元的相关数据,从而实现快速的调试和查看功能。

上层逻辑只需关心红点名和红点运算单元的定义以及红点名在逻辑层的绑定刷新即可。

这里我放一部分红点名和红点运算单元的初始化相关代码,详情参考Github源码:

  • 红点系统的初始化和更新驱动

    GameLauncher.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void Awake()
{
mSingleton = this;
RedDotModel.Singleton.Init();
RedDotManager.Singleton.Init();
// 所有数据初始化完成后触发一次红点运算单元计算
RedDotManager.Singleton.DoAllRedDotUnitCaculate();
}

public void Update()
{
RedDotManager.Singleton.Update();
}
******
  • 红点数据定义初始化

    RedDotModel.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
128
129
130
131
132
133
134
/// <summary>
/// 初始化
/// </summary>
public void Init()
{
if (IsInitCompelte)
{
Debug.LogError($"请勿重复初始化!");
return;
}
// 优先初始化红点单元,现在改成通过红点名正向配置红点单元组成,而非反向红点单元定义影响红点名组成
InitRedDotUnitInfo();
InitRedDotInfo();
InitRedDotTree();
// InitRedDotUnitNameMap必须在InitRedDotInfo之后调用,因为用到了前面的数据
UpdateRedDotUnitNameMap();
IsInitCompelte = true;
}

/// <summary>
/// 初始化红点运算单元信息
/// </summary>
private void InitRedDotUnitInfo()
{
// 构建添加所有游戏里的红点运算单元信息
AddRedDotUnitInfo(RedDotUnit.NEW_FUNC1, "动态新功能1解锁", RedDotUtilities.CaculateNewFunc1, RedDotType.NEW);
AddRedDotUnitInfo(RedDotUnit.NEW_FUNC2, "动态新功能2解锁", RedDotUtilities.CaculateNewFunc2, RedDotType.NEW);
AddRedDotUnitInfo(RedDotUnit.NEW_ITEM_NUM, "新道具数", RedDotUtilities.CaculateNewItemNum, RedDotType.NUMBER);
AddRedDotUnitInfo(RedDotUnit.NEW_RESOURCE_NUM, "新资源数", RedDotUtilities.CaculateNewResourceNum, RedDotType.NUMBER);
AddRedDotUnitInfo(RedDotUnit.NEW_EQUIP_NUM, "新装备数", RedDotUtilities.CaculateNewEquipNum, RedDotType.NUMBER);
AddRedDotUnitInfo(RedDotUnit.NEW_PUBLIC_MAIL_NUM, "新公共邮件数", RedDotUtilities.CaculateNewPublicMailNum, RedDotType.NUMBER);
AddRedDotUnitInfo(RedDotUnit.NEW_BATTLE_MAIL_NUM, "新战斗邮件数", RedDotUtilities.CaculateNewBattleMailNum, RedDotType.NUMBER);
AddRedDotUnitInfo(RedDotUnit.NEW_OTHER_MAIL_NUM, "新其他邮件数", RedDotUtilities.CaculateNewOtherMailNum, RedDotType.NUMBER);
AddRedDotUnitInfo(RedDotUnit.PUBLIC_MAIL_REWARD_NUM, "公共邮件可领奖数", RedDotUtilities.CaculateNewPublicMailRewardNum, RedDotType.NUMBER);
AddRedDotUnitInfo(RedDotUnit.BATTLE_MAIL_REWARD_NUM, "战斗邮件可领奖数", RedDotUtilities.CaculateNewBattleMailRewardNum, RedDotType.NUMBER);
AddRedDotUnitInfo(RedDotUnit.WEARABLE_EQUIP_NUM, "可穿戴装备数", RedDotUtilities.CaculateWearableEquipNum, RedDotType.NUMBER);
AddRedDotUnitInfo(RedDotUnit.UPGRADEABLE_EQUIP_NUM, "可升级装备数", RedDotUtilities.CaculateUpgradeableEquipNum, RedDotType.NUMBER);
}

/// <summary>
/// 初始化红点信息
/// </summary>
private void InitRedDotInfo()
{
/// Note:
/// 穷举的好处是足够灵活
/// 缺点是删除最里层红点运算单元需要把外层所有影响到的红点名相关红点运算单元配置删除
/// 调用AddRedDotInfo添加游戏所有静态红点信息
InitMainUIRedDotInfo();
InitBackpackUIRedDotInfo();
InitMailUIRedDotInfo();
InitEquipUIRedDotInfo();
}

/// <summary>
/// 初始化主界面红点信息
/// </summary>
private void InitMainUIRedDotInfo()
{
RedDotInfo redDotInfo;
redDotInfo = AddRedDotInfo(RedDotNames.MAIN_UI_NEW_FUNC1, "主界面新功能1红点");
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_FUNC1);

redDotInfo = AddRedDotInfo(RedDotNames.MAIN_UI_NEW_FUNC2, "主界面新功能2红点");
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_FUNC2);

redDotInfo = AddRedDotInfo(RedDotNames.MAIN_UI_MENU, "主界面菜单红点");
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_ITEM_NUM);
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_RESOURCE_NUM);
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_EQUIP_NUM);

redDotInfo = AddRedDotInfo(RedDotNames.MAIN_UI_MAIL, "主界面邮件红点");
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_PUBLIC_MAIL_NUM);
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_BATTLE_MAIL_NUM);
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_OTHER_MAIL_NUM);
redDotInfo.AddRedDotUnit(RedDotUnit.PUBLIC_MAIL_REWARD_NUM);

redDotInfo = AddRedDotInfo(RedDotNames.MAIN_UI_MENU_EQUIP, "主界面菜单装备红点");
redDotInfo.AddRedDotUnit(RedDotUnit.WEARABLE_EQUIP_NUM);
redDotInfo.AddRedDotUnit(RedDotUnit.UPGRADEABLE_EQUIP_NUM);

redDotInfo = AddRedDotInfo(RedDotNames.MAIN_UI_MENU_BACKPACK, "主界面菜单背包红点");
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_ITEM_NUM);
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_RESOURCE_NUM);
}

/// <summary>
/// 初始化背包界面红点信息
/// </summary>
private void InitBackpackUIRedDotInfo()
{
RedDotInfo redDotInfo;
redDotInfo = AddRedDotInfo(RedDotNames.BACKPACK_UI_ITEM_TAG, "背包界面道具页签红点");
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_ITEM_NUM);

redDotInfo = AddRedDotInfo(RedDotNames.BACKPACK_UI_RESOURCE_TAG, "背包界面资源页签红点");
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_RESOURCE_NUM);

redDotInfo = AddRedDotInfo(RedDotNames.BACKPACK_UI_EQUIP_TAG, "背包界面装备页签红点");
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_EQUIP_NUM);
}

/// <summary>
/// 初始化邮件界面红点信息
/// </summary>
private void InitMailUIRedDotInfo()
{
RedDotInfo redDotInfo;
redDotInfo = AddRedDotInfo(RedDotNames.MAIL_UI_PUBLIC_MAIL, "邮件界面公共邮件红点");
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_PUBLIC_MAIL_NUM);
redDotInfo.AddRedDotUnit(RedDotUnit.PUBLIC_MAIL_REWARD_NUM);

redDotInfo = AddRedDotInfo(RedDotNames.MAIL_UI_BATTLE_MAIL, "邮件界面战斗邮件红点");
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_BATTLE_MAIL_NUM);
redDotInfo.AddRedDotUnit(RedDotUnit.BATTLE_MAIL_REWARD_NUM);

redDotInfo = AddRedDotInfo(RedDotNames.MAIL_UI_OTHER_MAIL, "邮件界面其他邮件红点");
redDotInfo.AddRedDotUnit(RedDotUnit.NEW_OTHER_MAIL_NUM);
}

/// <summary>
/// 初始化装备界面红点信息
/// </summary>
private void InitEquipUIRedDotInfo()
{
RedDotInfo redDotInfo;
redDotInfo = AddRedDotInfo(RedDotNames.EQUIP_UI_WEARABLE, "装备界面可穿戴红点");
redDotInfo.AddRedDotUnit(RedDotUnit.WEARABLE_EQUIP_NUM);

redDotInfo = AddRedDotInfo(RedDotNames.EQUIP_UI_UPGRADABLE, "装备界面可升级红点");
redDotInfo.AddRedDotUnit(RedDotUnit.UPGRADEABLE_EQUIP_NUM);
}

******
  • 上层逻辑代码只关心红点的初始化,绑定和取消

    MainUI.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
/// <summary>
/// 绑定所有红点名
/// </summary>
private void BindAllRedDotNames()
{
RedDotManager.Singleton.BindRedDotName(RedDotNames.MAIN_UI_MENU, OnRedDotRefresh);
RedDotManager.Singleton.BindRedDotName(RedDotNames.MAIN_UI_MAIL, OnRedDotRefresh);
RedDotManager.Singleton.BindRedDotName(RedDotNames.MAIN_UI_MENU_BACKPACK, OnRedDotRefresh);
RedDotManager.Singleton.BindRedDotName(RedDotNames.MAIN_UI_MENU_EQUIP, OnRedDotRefresh);
RedDotManager.Singleton.BindRedDotName(RedDotNames.MAIN_UI_NEW_FUNC1, OnRedDotRefresh);
RedDotManager.Singleton.BindRedDotName(RedDotNames.MAIN_UI_NEW_FUNC2, OnRedDotRefresh);
}

/// <summary>
/// 响应红点刷新
/// </summary>
/// <param name="redDotName"></param>
/// <param name="result"></param>
/// <param name="redDotType"></param>
private void OnRedDotRefresh(string redDotName, int result, RedDotType redDotType)
{
var resultText = RedDotUtilities.GetRedDotResultText(result, redDotType);
if (string.Equals(redDotName, RedDotNames.MAIN_UI_MENU))
{
MenuRedDot.SetActive(result > 0);
MenuRedDot.SetRedDotTxt(resultText);
}
else if (string.Equals(redDotName, RedDotNames.MAIN_UI_MAIL))
{
MailRedDot.SetActive(result > 0);
MailRedDot.SetRedDotTxt(resultText);
}
else if (string.Equals(redDotName, RedDotNames.MAIN_UI_MENU_BACKPACK))
{
BackpackRedDot.SetActive(result > 0);
BackpackRedDot.SetRedDotTxt(resultText);
}
else if (string.Equals(redDotName, RedDotNames.MAIN_UI_MENU_EQUIP))
{
EquipRedDot.SetActive(result > 0);
EquipRedDot.SetRedDotTxt(resultText);
}
else if (string.Equals(redDotName, RedDotNames.MAIN_UI_NEW_FUNC1))
{
DynamicFunc1RedDot.SetActive(result > 0);
DynamicFunc1RedDot.SetRedDotTxt(resultText);
}
else if (string.Equals(redDotName, RedDotNames.MAIN_UI_NEW_FUNC2))
{
DynamicFunc2RedDot.SetActive(result > 0);
DynamicFunc2RedDot.SetRedDotTxt(resultText);
}
}

/// <summary>
/// 解绑所有红点名
/// </summary>
private void UnbindAllRedDotNames()
{
RedDotManager.Singleton.UnbindRedDotName(RedDotNames.MAIN_UI_MENU, OnRedDotRefresh);
RedDotManager.Singleton.UnbindRedDotName(RedDotNames.MAIN_UI_MAIL, OnRedDotRefresh);
RedDotManager.Singleton.UnbindRedDotName(RedDotNames.MAIN_UI_MENU_BACKPACK, OnRedDotRefresh);
RedDotManager.Singleton.UnbindRedDotName(RedDotNames.MAIN_UI_MENU_EQUIP, OnRedDotRefresh);
RedDotManager.Singleton.UnbindRedDotName(RedDotNames.MAIN_UI_NEW_FUNC1, OnRedDotRefresh);
RedDotManager.Singleton.UnbindRedDotName(RedDotNames.MAIN_UI_NEW_FUNC2, OnRedDotRefresh);
}

/// <summary>
/// 刷新红点显示
/// </summary>
private void RefreshRedDotView()
{
(int result, RedDotType redDotType) redDotNameResult;
redDotNameResult = RedDotManager.Singleton.GetRedDotNameResult(RedDotNames.MAIN_UI_MENU);
OnRedDotRefresh(RedDotNames.MAIN_UI_MENU, redDotNameResult.result, redDotNameResult.redDotType);

redDotNameResult = RedDotManager.Singleton.GetRedDotNameResult(RedDotNames.MAIN_UI_MAIL);
OnRedDotRefresh(RedDotNames.MAIN_UI_MAIL, redDotNameResult.result, redDotNameResult.redDotType);

redDotNameResult = RedDotManager.Singleton.GetRedDotNameResult(RedDotNames.MAIN_UI_MENU_BACKPACK);
OnRedDotRefresh(RedDotNames.MAIN_UI_MENU_BACKPACK, redDotNameResult.result, redDotNameResult.redDotType);

redDotNameResult = RedDotManager.Singleton.GetRedDotNameResult(RedDotNames.MAIN_UI_MENU_EQUIP);
OnRedDotRefresh(RedDotNames.MAIN_UI_MENU_EQUIP, redDotNameResult.result, redDotNameResult.redDotType);

redDotNameResult = RedDotManager.Singleton.GetRedDotNameResult(RedDotNames.MAIN_UI_NEW_FUNC1);
OnRedDotRefresh(RedDotNames.MAIN_UI_NEW_FUNC1, redDotNameResult.result, redDotNameResult.redDotType);

redDotNameResult = RedDotManager.Singleton.GetRedDotNameResult(RedDotNames.MAIN_UI_NEW_FUNC2);
OnRedDotRefresh(RedDotNames.MAIN_UI_NEW_FUNC2, redDotNameResult.result, redDotNameResult.redDotType);
}

有了这一套红点系统,上层逻辑定义红点主要由以下几个步骤组成:

  1. 定义红点名并初始化
  2. 定义红点名的红点运算单元组成并初始化
  3. 上层逻辑编写新红点运算单元的逻辑计算回调
  4. 上层逻辑绑定红点名刷新
  5. 上层逻辑触发红点名或红点运算单元标脏后,等待红点系统统一触发计算并回调

重点知识

  1. 前缀树用于实现字符串命名定义父子关系(仅仅只是调试信息上复制层级查看的父子关系)
  2. 红点名和红点运算单元通过组合的方式可以实现高度的自由组装(没有严格意义上的父子关系)
  3. 红点数据的标脏既可以基于红点运算单元也可以基于红点名,从而支持上层逻辑的准确标脏机制
  4. 如果没法在进游戏一个准确的实际出发红点运算单元全部计算,可以考虑每一个红点运算单元都通过对应模块触发标脏的方式触发计算
  5. 动态红点(比如背包不定数量道具的列表内红点)不再这套红点系统范围内定义,由上层逻辑自行触发计算和显示

Reference

Trie

Github

RedDotSystem

前言

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

需求

  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 : Int64
    {
    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管线局部数据黑名单目录配置窗口

通过上面的黑名单目录配置结合前面的局部预处理配置我们可以看出,我们希望ASTC6X6的设置对于Assets/Res/textures/TestBlackList目录生效但不希望对Assets/Res/textures/TestBlackList/BlackList目录生效,对于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管线系统里作为后处理数据依据。

平台纹理格式设置预处理器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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
/// <summary>
/// PlatformTextureFormatSet.cs
/// 平台纹理格式设置预处理器
/// </summary>
[CreateAssetMenu(fileName = "PlatformASTCSet", menuName = "ScriptableObjects/AssetPipeline/AssetProcessor/PreProcessor/Texture/PlatformTextureFormatSet", order = 1001)]
public class PlatformTextureFormatSet : BasePreProcessor
{
/// <summary>
/// 处理器名
/// </summary>
public override string Name
{
get
{
return "平台纹理格式设置";
}
}

/// <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>
[Header("目标平台")]
public BuildTarget BuildTarget = BuildTarget.StandaloneWindows64;

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

平台纹理格式设置预处理器对应*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
99
100
101
102
103
104
105
/// <summary>
/// PlatformTextureFormatSetJson.cs
/// 平台纹理格式设置预处理器Json
/// </summary>
[Serializable]
public class PlatformTextureFormatSetJson : BasePreProcessorJson
{
/// <summary>
/// 处理器名
/// </summary>
public override string Name
{
get
{
return "平台纹理格式设置";
}
}

/// <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 BuildTarget BuildTarget = BuildTarget.StandaloneWindows64;

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

/// <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)
{
// 如果设置的目标格式是ETC2,则通过判定是否有alpha决定设置ETC2_RGBA8还是ETC2_RGB4
var textureImporter = assetImporter as TextureImporter;
var platformName = EditorUtilities.GetPlatformNameByTarget(BuildTarget);
var platformTextureSettings = textureImporter.GetPlatformTextureSettings(platformName);
var textureFormat = TargetTextureFormat;
var isTargetETC2 = ResourceUtilities.IsETC2Format(TargetTextureFormat);
platformTextureSettings.overridden = true;
if(isTargetETC2)
{
textureFormat = textureImporter.DoesSourceTextureHaveAlpha() ? TextureImporterFormat.ETC2_RGBA8 : TextureImporterFormat.ETC2_RGB4;
}
platformTextureSettings.format = textureFormat;
textureImporter.SetPlatformTextureSettings(platformTextureSettings);
AssetPipelineLog.Log($"设置平台:{platformName}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();
}
}

效果展示:

CustomInspectorGUI

如果想实现通用的属性重置,可以参考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用于扩展指定脚本的Inspector操作栏

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

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 打断机制的实现