-
Notifications
You must be signed in to change notification settings - Fork 0
技术细节
- 使用UIElement构建的编辑器。
- 完整的C#代码,所有代码都是可见的,没有dll。
- 针对接口设计的架构,完全与业务逻辑解耦,更好的扩展性。
- 快速拆分内联子树,更好的分解和复用AI逻辑。
- 充分的性能优化,满足绝大多数游戏需求。
- 清晰的代码架构,完全模块化代码。
- 灵活的参数绑定,允许手动修改绑定路径。
- "com.unity.settings-manager": "1.0.3"
- 在Unity中打开Window -> Package Manager。
- 点击左上角的“+”并选择“Add package by name”。
- 输入UPM包的名字,版本可选填。
- 点击“Add”按钮,等待Unity下载和安装UPM包。
- 基础节点库实现
- 为Unity默认组件生成节点
- 视觉听觉感知组件
- 公开参数绑定
- List Array
Dictionary - 最多2层的嵌套泛型
- TreeElement循环引用。Task引用其他Task。
- 预制体可重写参数。
- 笔记节点StickyNote
- 组合Group
- 子树
- 文件变体
- 增加/删除节点
- 连接/断开节点
- 复制/粘贴节点
- 移动子节点时重新排序子节点
- 有未保存改动时关闭编辑器进行提示
- Undo/Redo
- 通过节点打开cs文件
- 通过节点打开节点View文件
- 节点序号
- 选中节点时,提示条件终止可影响的节点
- TwoChild节点创建特有的节点View
- 保存为ScriptObject文件
- 保存为Json文件
- 自动使用编辑器打开文件
- 生成行为树代码,消除反射,消除绑定
- 扫描所有行为树用到的类型,生成TypeCache.Hot静态方法代码。
- 保存编辑器用户偏好设置
- 编辑器Log可选设置
- 多语言Log
- 每个行为树文件和实例独立编辑器窗口
- 调试
- 支持多个实例同时调试
- 远程调试
- 游戏内实时编辑器/行为树查看器。(目前GraphView只支持编辑器,等unity realtime支持时迁移)
graph BT
BTNode --> BehaviorTreeElement
Log --> ActionTaskNode --> BTTaskNode-->BTNode
Wait --> ActionTaskNode
Selector --> CompositeNode --> BTParentNode
Sequence --> CompositeNode
Repeater --> OneChildNode --> BTParentNode-->BTNode
SimpleParallel --> TwoChildNode --> BTParentNode
BTDecorator --> BehaviorTreeElement
CheckEvent ---> ConditionDecorator --> BTDecorator
KeyCodeEvent ---> ConditionDecorator
Inverter ----> BTDecorator
Loop ----> BTDecorator
Tick函数内部大致调用过程,具体细节请阅读源码。
graph TB
subgraph Return [Return State]
direction TB
rs(Succeeded)
rf(Failed)
end
subgraph Condition [Condition]
direction TB
ecd>ExecuteConditionDecorator]
a([IsCheckedCanExecute])
a--> |false|CanExecute
CanExecute --> |all|ecd
CanExecute -->|Set:true|a
a--> |true|CanExecuteWithAbortSelf
CanExecuteWithAbortSelf --> |OnlyAbortSelf|ecd
end
subgraph Execute
direction TB
epred>ExecutePreDecorator]
epostd>ExecutePostDecorator]
epred --> |IsCompleted:false| Running --> |IsCompleted| epostd
epred --> |IsCompleted| epostd
end
subgraph Running
direction TB
enter("(FirstRun)Enter/OnEnter") ---> |Running| OnTick(((OnTick)))
OnTick ---> |IsCompleted| Exit/OnExit
OnTick ----> |Running| rr(Return State Running)
enter --> |IsCompleted| Exit/OnExit
end
subgraph AbortSelf
direction TB
ead>ExecuteAbortDecorator]
Abort/OnAbort --> callExit[Call Exit if need] --> ead
end
e([Enabled])
e --> |true| Condition
Condition --> |true| Execute
Execute --> ResetFlag
e --> |false| AbortSelf
Condition --> |false| AbortSelf
AbortSelf --> ResetFlag
ResetFlag --> Return
graph LR
BindAgent --> ParseBinding ---> |FirstTick| Awake --> Start
BindAgent,ParseBinding 在 Awake前执行。
BindAgent,ParseBinding被看作反序列化的一部分,Awake可能会用到一些序列化的成员,至少会用到GameObject。
本库采用的设计是,装饰器放在Task内部,并认为装饰器是Task的一部分。
依据是:在状态机中,条件节点从来就不是一个状态,而是一个过渡,过渡不是一等成员,不能独立存在与状态机中,只能依附于状态节点。
笔者认为行为树将条件节点作为执行节点放在树中是一个失败设计。
采用了装饰器放在Task内部的设计,那么有下面2种设计:
- 先判断条件后进入Task (依据是:装饰器等价于其他行为树中的条件节点,只有经过了条件节点,才能到达Action节点)
- 先进入Task后判断条件 (依据是:如果不进入Task,那么不应该访问Task内部内容)
哪种设计更合理?
目前采用第一种设计方案。
于此同时,装饰器等价于其他行为树中的 条件节点/控制节点。所以装饰器成员结构上与Note相似,同样含有GUID等成员。
-
条件装饰器
用C↓表示,从上到下执行 用于判断节点能否进入 -
前置装饰器
用F↓表示,从上到下执行 在进入节点前调用 -
后置装饰器
用B↑表示,从下到上执行 在退出节点后调用 -
终止装饰器
用A↑表示,从下到上执行 在终止节点发生时退出节点后调用
- 如果
条件装饰器
没有通过,则不会执行节点。前置装饰器,后置装饰器,终止装饰器都不会被执行。 -
后置装饰器
和终止装饰器
不会被同时执行。
但是节点本身的OnAbort OnExit都会被执行。
Abort时调用顺序 OnAbort --> (如果执行过Enter)Exit -->终止装饰器
使用Subtree节点,可以引用另一个行为树文件,作为子树使用。
- 内联
将子树节点的行为树节点内联到当前树中,并使用子树的开始节点,取代子树节点。
内联时所有子树节点的GUID都会更新为新值,防止GUID冲突。 - 导出
选中一个节点,将该节点和它的子节点导出为一个新的行为树。
所有导出的节点GUID都会更新为新值,防止GUID冲突。
主要是防止用户导出后又进行Undo操作,会导致出现相同GUID的节点。
由于架构设计原因,无法分析参数表与节点的引用关系,引用的参数表参数不会跟随导出到新树中。
注意:内联和导出都是高危操作,非常容易对行为树文件造成不可逆更改。强烈建议在操作前使用版本管理工具。
- 多态序列化可以使用,但是如果注意,如果直接清除序列化对象,重新赋值新对象时,rid会改变。
直接修改原对象时rid不会改变。 - 泛型序列化unity2023才开始支持。插件主要支持版本还是2021,现阶段不能依赖于这个功能。所以还是要泛型特化。
参数绑定涉及到的4个功能是正交
的。
- 包含一个基础值,作为普通成员来使用。
- 包含一个Path,纪录绑定到Agent的某个组件的成员。
包含一个ParseMode,用于在解析绑定失败时的行为,包括Log,抛出异常,回退到基础值,回退到类型默认值。 - 包含一个RefName,可以存放到参数表集合中,也可以在多个节点上共享。也可以看作这个成员值引用了一个参数表中的值。
- AutoConvert,存放一个参数引用,引用的参数类型可以和声明类型不一致,使用自动转换器来匹配。
本库通过继承逐步递进实现了4种功能参数。节点的成员值可以声明为这4种。
如果使用1和2的类型,序列化是直接序列化到节点本身的,等同于内嵌到节点的,不与节点外界发生关系。
如果使用3和4的类型,参数是序列化到树的参数表中的,在节点本身只序列为引用Name,实例化时通过搜索参数表中的参数,赋值给成员。
自动类型转换存在2个部分。
- 参数实例Path可以绑定到与参数声明类型不同的Agent的某个组件的成员。
- TODO: 节点成员值可以引用参数表中与成员声明类型不同的参数实例。
显而易见的是,转换次数越多,性能越低。
- 序列化成员更改命名。保证仍能反序列化成功。
- 支持FormerlySerializedAsAttribute特性
- 支持SerializationAliasAttribute特性
- 节点更换类名
- 使用SerializationAliasAttribute特性标记在类型上,注意别名一定是旧的类型全名,包含命名空间。
编辑器会自动查找SerializationAlias保证旧的文件仍能反序列化成功。重新保存后变更为新名字。 - 或者手动写一个静态函数,在反序列化前使用TypeCache.HotType设置别名。
- 使用SerializationAliasAttribute特性标记在类型上,注意别名一定是旧的类型全名,包含命名空间。
查找特性比较耗时,推荐更换命名后,重新保存下已有的行为树文件,将名字更新。
如果需要运行时解析旧名字,同理,在游戏开始时,反序列化之前使用TypeCache.HotType设置别名。
节点序列化成员类型变更时,由于类型不匹配,会导致原有行为树文件反序列失败。
在成员或者节点类型上使用SetMemberByAttribute,指定一个反射赋值时的回调方法。
成员回调方法签名是 bool FuncName(object memberValue);
类型回调方法签名是 bool FuncName(string memberName,object memberValue);
方法不能是私有的或者静态的。
可以在节点中声明引用含有Serializable特性的节点。 使用BindingPathSetterAttribute,则可以在Inspector中选择树种的节点引用。
- 环形拓扑本质上已经非常接近分层状态机了,每个组合节点都可以看作一个状态机的层。
- 菱形拓扑可以避免常用逻辑在图中多次复制。
运行时的树是没用结构限制的。
编辑器限制不允许环形拓扑和菱形拓扑。
但是用户可以手动编写行为树文件。
超出树形拓扑会造成蜘蛛网一样的图,失去可读性。用户自行取舍。
- 事件驱动(event-driven)
- 轮询驱动(tick-driven)
- root-leaf
从root节点tick到末端leaf节点。优点是实现简单,容易理解。 - last leaf
记录最后执行的leaf节点,每次tick最后的节点。优点是性能更好一点。 - active stack
使用执行栈的方式记录下次要执行的节点。
- root-leaf
本库使用轮询驱动root-leaf
模式执行行为树。
Q:为什么不采用事件驱动(event-driven)行为树?
A:实现起来太过于复杂,涉及到参数绑定值变化时没有办法处理。
事件驱动实现基础是存在Blackboard,并且Blackboard SetValue触发更新。
本库不存在Blackboard,绑定后没有SetValue,类似与Lazy模式,每次取值时才计算值,无法触发事件,优点是不用手动SetValue。
事件驱动优点是性能更高,缺点是因为没有tick所以需要service节点。
轮询驱动优点是实现简单。
Q:节点完成后应该立刻进入下一个节点
还是下一次tick才进入下一个节点
?
A:采用立刻进入下一个节点
的设计方案。
- 必须保证有一个叶子节点为running状态。
如果下一个tick才切换,会导致当前tick,当前节点完成,下一个节点未开始运行。不符合直觉。 - 类比状态机,状态机中不允许某一时刻处于2个状态夹缝状态,总是立刻进入下一个状态。
- 装饰器等同于常规行为树的叶子节点。而且装饰器很难实现下一次tick才进入下一个装饰器。
为了保证装饰器和叶子节点的一致性,统一为立刻进入下一个节点。
在节点已经开始执行,并且没有完成时,主动结束节点。
当Abort发生时,递归调用子节点,Abort执行OnAbort Exit AbortDerators。
最终由子到父,依次退出。
[Flags]
public enum AbortType
{
None = 0,
Self = 1 << 0,
LowerPriority = 1 << 1,
Both = Self | LowerPriority
}
两种终止类型:
- Self:当自身处于
Running
时,每次Tick都对含有AbortType.Self的条件装饰器重新求值。如果结果由true变为false,则终止自身节点,节点向父节点返回Failed。 - LowerPriority:当
右侧节点
处于Running
时,父节点每次Tick会额外对HasAbortLowerPriorityFlag
的节点重新求值。
如果节点的执行结果发生改变
,则终止处于Running
的节点。
如果节点的执行结果没有改变
,则继续执行Running
的节点。
不要被终止类型的名词误导,不同的行为树实现条件终止的处理方式略有差异。
但总的来说是都是符合直觉的。可以笼统的理解为,已经处理过的节点条件改变时,允许反悔,重新执行,并结束正在允许的节点。
- Selector
对HasAbortLowerPriorityFlag
的子节点重新求值。 - Sequence
仅对HasAbortLowerPriorityFlag
的子节点的含有AbortType.LowerPriority的条件装饰器
重新求值:- 如果重新求值结果为true,跳过节点本身再次求值,继续执行处于
Running
的节点。 - 如果重新求值结果为false,则终止处于
Running
的节点,Sequence直接返回Failed。
- 如果重新求值结果为true,跳过节点本身再次求值,继续执行处于
- Dynamic是另一种条件终止的实现方式。
- Dynamic仅可以标记在父节点上。
- Dynamic比AbortType性能开销更高,也更不容易理解。
- 强烈建议使用AbortType,而不是Dynamic。 但是仍然尊重用户的选择。
不同的父节点对Dynamic的处理方式不同。
- Selector 标记为Dynamic
每次Tick对所有子节点重新求值。 - Sequence 标记为Dynamic
与AbortType.LowerPriority
不同。
每次Tick对所有子节点重新求值:- 如果这个节点Succeeded,则继续执行处于
Running
的节点。 - 如果这个节点Running,则终止处于
Running
的节点,改为执行这个节点。 - 如果这个节点Failed,则终止处于
Running
的节点,Sequence直接返回Failed。 -
特别注意
:如果Sequence含有多个非条件子节点,第一个非条件节点总是会终止后面的非条件节点,并重新运行。会造成死循环。所以如果没有特别需要,不要对Sequence标记Dynamic。
- 如果这个节点Succeeded,则继续执行处于
Event和Trigger都是记录在树中的标记。
- Event生命周期为1个Tick,1个Tick后自动消除。
有效期内,任何时刻任何节点检查Event结果都为true。 - Trigger生命周期为永久,直到ResetTrigger。
有效期内,任何时刻任何节点检查Event结果都为true。
通常CheckTrigger装饰器可以选择何时ResetTrigger
- Immediate 检测成功后立刻ResetTrigger
- EnterNode 检测成功后如果能成功进入所属节点,ResetTrigger
- LeaveNode 检测成功后如果能成功进入所属节点,离开节点时ResetTrigger
默认是Immediate。
某些用例,节点上可能有多个条件节点,如果不是所有条件节点都成功,则节点不会进入执行。
这种情况下可能不希望消耗掉Trigger,则可以设置为EnterNode。
初始化行为树实例可能会消耗大量时间。
尤其是初始化第一个行为树实例,可能需要初始化很多静态成员,消耗时间可能在100~1000ms,甚至更多。
建议多线程初始化。
简单树实例(20个节点以内),每次Tick大约0.002ms ~ 0.02ms。
- 行为树越复杂消耗性能越多。
- 行为树深度对性能影响比较大。
- 出现异常会影响性能。
- 打印Log会严重影响性能。
尖峰帧卡顿,个别帧可能消耗10倍以上性能,单个行为树实例执行时间可能超过1ms。
可能的原因:
- 绑定参数深度太深。
- 绑定参数过多。
- 条件终止过多。
性能与Animator相差太多。Animator保守估计性能也是插件的10倍以上。
结论是:
超出场景范围的对象行为树应该关闭。关闭的行为树不会执行,只会占用内存。
不应该同时存在过多的行为树实例,建议不超过100个。
优化:
- 如果明确行为树不会调用主线程函数,可以考虑手动使用多线程执行。
- 降低行为树执行频率,每隔固定时间执行一次,将行为树平均分配到多个帧执行。
- 使用自定义节点,代替通用节点和参数绑定。这对复杂行为树性能至关重要。
- 生成行为树代码,代替行为树资源文件。
在服务器中使用行为树:
如果想在服务器使用大量行为树实例,应该生成行为树代码。
即使如此,想要在服务器同时执行数千数万的实例,也是难以实现的。
默认节点和默认节点的默认行为参数可能无法满足用户的所有需求。
由用户根据项目需要扩展节点是非常常见的。
应当首选从框架提供的基类接口继承。
如果基类不符合需求,可以考虑通过接口继承。相当一部分功能编辑器是直接通过接口支持。
如果运气好的话,即使扩展节点缺失了某些接口,框架仍能正常工作。
随着架构重构,功能总是从基类向接口拆分,耦合越来越低。
使用异步API,比如父节点调用子节点等待结果,使用异步代替事件。Running可以使用异步代替。
实际实现时发现,使用异步就不应该Tick驱动,只有事件驱动才能完全发挥异步的效果。
这与参数绑定是相互冲突的设计。如果强行使用异步,就要增加一个绑定参数求解器,然后不停的Tick,当参数值发生改变时,发送事件到行为树,模拟事件驱动。
使用异步实现起来更加繁琐。开销更大。所以暂时不采用这种方式。