unity3d 使用BehaviourMachine时遇到的神坑——关于节点执行时机的种种

BehaviourMachine(下文简称{b})是一个把BehaviourTree和HierachicalFSM整合到一起的一个系统。
BT中的一个节点可以是一个状态机,状态机中的一个状态可以是一个BT,听起来就特别适合用来创建高度复杂的层次化逻辑,你可以感受到前所未有的逻辑组织能力。
上手之后给我的感觉是功能比较清晰,编辑器直观易用,除了内置的Action少了点以外(这并不妨碍,因为只要不是做原形,就不会用到细粒度的Action,而是在代码中实现极具针对性的Action,否则树太庞大也不好维护。),一切都很美好,直到我遇到了这个事:

图片描述

在上图中,根行为树中有三个节点,分别是:
1 输出 "Before SubTree"
2 执行子行为树
3 输出 "After SubTree"
在我的子行为树中,只输出一句"Update SubTree"

按照节点摆放顺序来理解的话,我每帧应该得到这样的三行输出。
1> Before SubTree
2> Update SubTree
3> After SubTree

然而从图中的橙色方块内却看到,实际的执行顺序是Update SubTree被放在最后执行了。

图片描述

尼玛啊!

在百思不得骑姐之中我深入了{b}的代码,最终发现了一些惊天大秘密!!!


BehaviourTree, StateMachine, ActionNode 你们究竟是什么??

先看看类继承关系图:

图片描述

在{b}系统中活跃的就是这三大类了:InternalStateBehaviour、ActionNode、Blackboard。
Blackboard比较本分,关系简单逻辑也简单,不喜欢和别人搅在一起。
我今天主要需要讨论InternalStateBehaviour和ActionNode。

InternalStateBehaviour是什么?

我们可以这样理解,从InternalStateBehaviour继承下来的类型可以作为某种容器。这个容器中可以存放很多的ActionNode,也可以存放InternalStateBehaviour。
这样,从你手动添加到你的GameObject上的那个InternalStateBehaviour开始,它的里面有若干ActionNode,也可能有一个或多个子InternalStateBehaviour,然后这个InternalStateBehaviour也如上,就形成了{b}的层次化嵌套体系。

在什么情况下,一个InternalStateBehaviour中会有子InternalStateBehaviour呢?
比如一个BT内有一个StateNode节点来驱动一个StateMachine。
比如一个BT内有一个StateNode节点来驱动一个ActionNode。
再比如一个BT内有一个StateNode节点来驱动另一个BT。
以及所有在StateMachine中创建子StateMachine或任何一种State。

图片描述
左边是StateMachine的情况,绿框中每个State都是一个子InternalStateBehaviour
右边是BehaviourTree的情况,绿框列表中每一项都是一个子InternalStateBehaviour

除此之外,InternalStateBehaviour更具广泛性的职责是,可以作为逻辑执行的宿主。
比如BehaviourTree作为逻辑发起者执行它所拥有的所有ActionNode的逻辑。
关于这个,在后面篇幅会再次提到。

ActionNode是什么?

当你选中任何一个ActionNode容器的时候(主要是BehaviourTree和ActionState),都可以在这里创建各种ActionNode来组织逻辑。

图片描述
左边是ActionState中的ActionNode列表
右边是BehaviourTree的ActionNode树

ActionNode就是一个C#对象,它甚至不是UnityObject,也不是可被Unity序列化的。
我们在编辑器中编辑和创建的ActionNode将会被{b}用一套特有的方式保存,然后在运行时读取这些数据动态创建出ActionNode来。
在ActionNode中有一个比较特殊的叫做StateNode,它可以引用一个宿主容器中所包含的InternalStateBehaviour。
这样就可以做到这样的包含关系BehaviourTree -> StateNode -> StateMachine(InternalStateBehaviour) -> BehaviourTree -> ...


在行为树中加入子状态节点时的执行顺序

{b}怎么做到如此强大的嵌套能力?

StateMachine嵌套StateMachine ———— 通过让每一个StateMachine也是一个State
StateMachine嵌套BehaviourTree ———— 通过让BehaviourTree也是一个State
BehaviourTree嵌套StateMachine ———— 通过StateNode,它可以引用一个State

关于InternalStateBehaviour的运行方式

本话题主要讨论的是BehaviourTree和ActionState两个类,因为其它的InternalStateBehaviour实际上不是ActionNode的容器,也就不是本文的关注点。
一个BehaviourTree的基本结构是顶层为FunctionNode(你可以在上面的类继承关系图中找到它),默认情况下至少会有OnEnableUpdate两个FunctionNode,由FunctionNode之下可以添加ActionNode节点,如果这个ActionNode节点是一个BranchNode则它下面还可以有子节点。
在继承关系中可以看出,在整个树状结构中所有节点(FunctionNode, CompositeNode, DecoratorNode等等)其实都是ActionNode。而所有的这些ActionNode,最后一定会有一个FunctionNode的根节点。
于是在BehaviourTree的代码里,所有的FunctionNode都保存在一个数组里:

    [System.NonSerialized]
    FunctionNode[] m_FunctionNodes;

那么,当特定的事件发生的时候,如何通知对应的FunctionNode,让它开始执行呢?
让我们看看最常用的一个FunctionNode的代码:

    public class Update : FunctionNode {

        public override void OnEnable () {
            if (this.enabled) {
                InternalGlobalBlackboard.update += OnTick;
                m_Registered = true;
            }
        }

        public override void OnDisable () {
            InternalGlobalBlackboard.update -= OnTick;
            m_Registered = false;
        }
    }

OnTick的定义在ActionNode类中:

    public void OnTick () {
        if (m_Status != Status.Running)
            this.Start();

        m_Status = this.Update();

        if (m_Status != Status.Running)
            this.End();

        this.OnNodeTick();
    }

this.Update()会调到FunctionNode类中:

    public override Status Update () {
        var childStatus = Status.Error;

        // Tick children
        for (int i = 0; i < children.Length; i++) {
            children[i].OnTick();
            childStatus = children[i].status;
        }

        return childStatus;
    }

最后,在BehaviourTree中调用ActionNode的OnEnable(),就可以让整个FunctionNode生效了:

    public virtual void OnEnable () {
        // Add this parent to the blackboard to receive system events
        if (isRoot)
            blackboard.AddRootParent(this);

        // Load nodes
        if (m_Nodes == null)
            this.LoadNodes();

        // Call OnEnable in nodes
        if (!m_NodesEnabled)
            this.OnEnableNodes();

        // Call onEnable event
        if (onEnable != null)
            onEnable();
    }
    void OnEnableNodes () {
        m_NodesEnabled = true;

        if (m_Nodes != null) {
            for (int i = 0; i < m_Nodes.Length; i++)
                m_Nodes[i].OnEnable();
        }
    }

于是,我们可以确立这样一个执行顺序:

  1. 由GlobalBlackboard接收UnityMessage,然后通过静态委托来分发给所有向它注册过的FunctionNode。(上面代码的InternalGlobalBlackboard.update)实际情况是会调用FunctionNode的OnTick()方法。

  2. FunctionNode调用自己的Update(),并且按顺序调用每个子节点的OnTick()

  3. 每个子节点在被调用OnTick()时会调用自己的Update()。如果这个节点有子节点,则继续按顺序调用每个子节点的OnTick()

深度优先遍历,Happy Ending ?

没这么简单。

既然这样,那么为什么会出现第一张图片那种情况呢?

这就牵扯到StateNode作为一个ActionNode的特殊性了。
让我们再重新看一下这个行为树:

图片描述

看到这个图,直觉的理解就是:

[MainTree]BehaviourTree::Update()
    [PrintLog]LogNode::OnTick()
        Debug.Log("Before SubTree")
    [SubTreeNode]StateNode::OnTick()
        [SubTree]BehaviourTree::Update()
            [PrintLog]LogNode::OnTick()
                Debug.Log("Update SubTree")
    [PrintLog]LogNode::OnTick()
        Debug.Log("After SubTree")

但故事肯定不能是这样啊!
请回想上一小节讨论的内容,[SubTree]BehaviourTree::Update()是应该从[SubTreeNode]StateNode::OnTick()调进去的吗??
如果你打开StateNode.cs的代码的话你会看到这样的东西:

    public class StateNode : ActionNode {
        // ...
        
        public override Status Update () {
            // Validate members
            if (state == null)
                return Status.Error;
            return state.OnTick(); //InternalStateBehaviour.OnTick()
        }
     
        // ...       
    }

然后:

    public class InternalStateBehaviour : MonoBehaviour {
        /// <summary>
        /// Called by a StateNode inside a BehaviourTree or ActionNode.
        /// This let you use a InternalStateBehaviour as a task node.
        /// <returns>The execution status.</returns>
        /// <seealso cref="BehaviourMachine.StateNode" />
        /// </summary>
        public virtual Status OnTick () {
            return Status.Running;
        }
    }

它什么也没干,直接返回了Status.Running。。。。

实际上MainTreeSubTree虽然存在从属关系,但是它们在调用Update()的入口上都是一样来自FunctionNode向GlobalBlackboard注册的委托。从这个角度上讲,他们其实是在同一层级执行的。
实际情况更接近于这样:

GlobalBlackboard::Update()
    [MainTree]BehaviourTree::Update()
        [PrintLog]LogNode::OnTick()
            Debug.Log("Before SubTree")
        [SubTreeNode]StateNode::OnTick()
            return Status.Running
        [PrintLog]LogNode::OnTick()
            Debug.Log("After SubTree")
            
    [SubTree]BehaviourTree::Update()
        [PrintLog]LogNode::OnTick()
            Debug.Log("Update SubTree")

所以一切都解释得通了。

这样合理吗?

这样的执行方式确实违反直觉,甚至给人不合理的感觉,但是为什么不设计成所谓"合理"的样子呢?
比如让这些OnTick就用遍历树的方式一直向下遍历。
如果可以这样的话也好,使用起来符合直觉,不需要关心它内部机制造成的特殊执行顺序。

但是InternalStateBehaviour并不能这么简简单单的被安插到BehaviourTree的一个节点中。
每一种不同的InternalStateBehaviour都可以响应不止一个事件,拿BehaviourTree来说,它的默认情况下就会响应至少两个事件:OnEnable Update
这一点上ActionNode就非常简单,它只响应一个事件OnTick
所以InternalStateBehaviour和ActionNode不可同日而语,每一个不同的InternalStateBehaviour实际上都是一个具有自主事件感知能力的主体,这也是为什么所有InternalStateBehaviour组件都要被设计为一种MonoBehaviour。

让InternalStateBehaviour成为一种直接受到BehaviourTree控制的节点会导致概念混淆不清的问题出现。
比如下图:

图片描述

那么在主行为树的OnEnable事件下的StateNode(2)执行的时候,是应该调用它的OnEnableFunctionNode还是UpdateFunctionNode呢?

StateNode到底是个什么Node?

一般的ActionNode在BehaviourTree中,担任的是执行某一个行为的职责。
而由于InternalStateBehaviour功能过于强大,无法被视作一个执行器,所以StateNode实际的职责是控制InternalStateBehaviour的作用于,通俗的讲就是控制它的开启和关闭。

图片描述
通过条件来决定SubTreeNode的执行状态


但是我确实需要在BehaviourTree中控制StateNode的执行顺序,怎么办?

如果是简单的情况也是可以做到的。
虽然StateNode打破了ActionNode的执行顺序,但是StateNode于StateNode之间的执行顺序是可以保证的。
我可以把开始的例子稍作修改,就可以得到想要的执行顺序了。

图片描述


BTW:关于ActionState和IFixedUpdateNode,IGUINode接口

源代码中对IFixedUpdateNodeIGUINode的描述是

Decorate ActionNodes with this interface when they should be ticked in the FixedUpdate.
Decorate ActionNodes with this interface when they should be ticked in the OnGUI.

但是不要傻傻的以为给你的自定义ActionNode实现了这个接口,它就会魔法般的在正确的函数中执行了哦!

这两个接口是专门设计用来和ActionState一起工作的。
因为ActionState并不是一个BehaviourTree,而是更加接近一个ActionSequence这样的存在。
所以在ActionState中并不能自由的添加FunctionNode,也不能添加CompositeNode。而只有一个Every Frame这样的特殊时机。

实际上在ActionNode中隐式存在着4个不同的事件,它们分别是:OnEnable,Update,FixedUpdate,OnGUI
如果你的ActionNode被安排在Every Frame的上方,那么它会被分配在OnEnable时执行。如果你的ActionNode被标记为IFixedUpdateNode或者IGUINode,那么它会在FixedUpdateOnGUI时执行,如果不属于以上情况,则在默认的Update里执行。

但是这套系统是为了具有简化的逻辑编辑能力的ActionState而准备的,而具有完整逻辑编辑能力的BehaviourTree则不适用。

所以注意,虽然AddForce这种ActionNode有IFixedUpdateNode修饰,但是在BehaviourTree里,如果你把它安插在了Update里,那它就只能在Update里执行了。
另外还需注意,因为ActionState会擅自的把你的逻辑序列拆成多个互不相干的逻辑序列并且还在不同的时机执行,所以编写逻辑序列的时候一定要想清楚自己在做什么。
像下图这种做法是无法让物体动起来的哦~

图片描述
因为AddForce会在FixedUpdate时执行,所以生效的$Force是1而不是20。


总结

把BehaviourTree和StateMachine组合在一起是一个不错的尝试。
但是按照现实中的需求来讲,其实并不需要像{b}提供的这种无限层级的互相嵌套。
{b}所做的尝试已经是相当程度的成就,但也有很多问题暴露了出来,换做其它人来做,很多细节规则和隐藏问题也是充满了挑战。

正是因为这个原因,在使用{b}的时候必须要多留心,很多地方需要了解到它的内部机制才能安全的编写逻辑。
虽然有这么多问题,{b}依然是我最喜欢的逻辑组织用中间件。因为它囊括了HFSM, BT, ActionSequence,而且还很简洁!

晚安!

本人才疏学浅,只是针对自己在实际使用中遇到的问题进行研究,仓促之间也不能全面的了解BehaviourMachine中间件。如果读者在阅读时发现任何问题,希望不吝指正。有任何想法打算交流,欢迎给我发邮件。(账户的电子邮箱真的在用哦~)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值