Behaviour trees
Behaviour trees can be used to model AI behavior of entities. An ECS can be used for the same purpose, and, as we will see later in the tutorial, the way we integrate with BehaviorTree.CPP (BehaviorTree from now on) is very similar to how we define an ECS in Simulate.
In this tutorial we are going to be using version 3.5.6 of the BehaviorTree.CPP library.
Note: Even though our tutorial is mostly about BehaviorTree.CPP, most of the things described here can still be reused to integrate with other behaviour tree libraries.
There are 3 main places where we can instantiate behaviour trees.
For starters, we can have a global behaviour tree, although it isn't really useful since we don't have access to entities' state to drive their behaviour. We could share entity state via messages, however this approach won't scale well when you have thousands of entities.
Let's assume we have a component called
agent_robot
, which we want to add some behaviour to.struct agent_robot {
int state;
BT::Tree tree;
};
For our purposes we just want to modify the robot's state every X seconds. In order to drive our behaviour tree, we need to call
.tickRoot()
on it. Assuming we are using entt, our cell_tick
function would look like:// ./Simulation/simulate.cc
void user_cell_state_impl::cell_tick(const aether_state_type &aether_state, float delta_time) {
aether_state
.registry
.view<agent_robot>(entt::exclude<aether::entt::component::ghost>)
.each([&] (agent_robot &r) { r.tree.tickRoot(); });
}
This works really well provided that:
- We don't need access to other components (like the robot's coordinates)
- We don't need to receive/send messages
Note:
- We're not showing the implementation of the tree, since the point of this section is to show when BehaviorTree can be used as a component.
- When we are serialising
agent_robot
, we don't need to serialisetree
since the tree itself is stateless.
The place where we have access to our message buffers, and entities is the cell. We will be using BehaviorTree instead of a dedicated system, but we will see later in the tutorial, that they basically achieve the same purpose.
Let's create a very simple behavior tree:
// ./Simulation/robot_behavior.hh
#pragma once
#include <behaviortree_cpp_v3/behavior_tree.h>
#include <behaviortree_cpp_v3/bt_factory.h>
#include <aether/types.hh>
#include <aether/handover.hh>
#include <aether/octree_params.hh>
#include <aether/ecs/ecs.hh>
#include <aether/common/morton/traits.hh>
#include <aether/entt.hh>
#include <aether/entt/serialization.hh>
#include <entt/entt.hpp>
struct entt_store_traits;
static const char* xml_text = R"(
<root main_tree_to_execute = "MainTree" >
<BehaviorTree ID="MainTree">
<Sequence name="root_sequence">
<Fallback name="moving">
<moveToPosition name="move_to_position"/>
<requestWork name="request_work"/>
</Fallback>
</Sequence>
</BehaviorTree>
</root>
)";
class robot_behavior {
// some useful aliases
using store_type = aether::entt::store;
using aether_state_type = aether::entity_based_user_state_base<entt_store_traits>;
// our behaviour tree
BT::Tree robot_tree;
// the "robot" entity we are going to move
entt::entity eid = entt::null;
// used to implement the movement logic
float delta_time = 0.0f;
// to access a cell's entities
store_type &store;
// to send messages
aether::message::message_buffer &outgoing_message_buffer;
// the worker assigned to our current cell
aether::worker_id wid;
public:
robot_behavior(store_type &aether_store,
aether::message::message_buffer &_omb,
aether::worker_id worker_id) :
store(aether_store), outgoing_message_buffer(_omb), wid(worker_id)
{
BT::BehaviorTreeFactory robot_bt_factory;
robot_bt_factory.registerSimpleAction("moveToPosition", std::bind(&robot_behavior::move_to_position, this));
robot_bt_factory.registerSimpleAction("requestWork", std::bind(&robot_behavior::request_work, this));
robot_tree = robot_bt_factory.createTreeFromText(xml_text);
}
BT::NodeStatus move_to_position();
BT::NodeStatus request_work();
void tick(float _delta_time);
};
In the snippet above we are creating a new struct which is going to wrap our behaviour tree. It will also store some important things, like our cell's state so that we can actually apply our behaviour tree to entities.
Our implementation will look like:
// ./Simulation/robot_behavior.cc
#include "robot_behavior.hh"
#include "user_definitions.hh"
using vector_type = aether::vec2i64;
using octree_traits = aether::octree_traits<vector_type>;
BT::NodeStatus robot_behavior::move_to_position() {
auto ®istry = store.registry;
auto &move = registry.get<agent_move>(eid);
auto &robot = registry.get<agent_robot>(eid);
if (!robot.should_move) {
return BT::NodeStatus::FAILURE;
}
// ... movement logic
return BT::NodeStatus::SUCCESS;
}
BT::NodeStatus robot_behavior::request_work() {
auto &move = store.registry.get<agent_move>(eid);
auto &robot = store.registry.get<agent_robot>(eid);
auto writer = outgoing_message_buffer.get_message_writer<octree_traits>(wid);
// ... use writer to send message
return BT::NodeStatus::SUCCESS;
}
void robot_behavior::tick(float _delta_time) {
delta_time = _delta_time;
auto view = store.registry.view<agent_robot>();
for(auto entity: view) {
eid = entity;
robot_tree.tickRoot();
}
}
Let's take a closer look at what is happening. Our
tick
function will call tickRoot
for all entities that have a agent_robot
component. This is done by setting the eid
member to a new entity each iteration, before we call tickRoot
. The tickRoot
function from BehaviorTree, goes through our tree that we defined in our XML string, and executes move_to_position
first. If that returns FAILURE
, then it calls request_work
, otherwise it just finishes executing. So our robot will move if it's in the right state, if not it will request a new position. For the sake of completion, let's see how these components are defined:// ./Simulation/user_definitions.hh
struct agent_move {
aether::vec2f position;
};
struct agent_robot {
bool should_move;
aether::vec2f target;
};
typedef std::tuple<agent_move, agent_robot> component_types;
AETHER_SERDE_DERIVE_TRIVIAL(agent_move)
AETHER_SERDE_DERIVE_TRIVIAL(agent_robot)
There are two more things we need to do to plug this into our simulation:
// ./Simulation/simulate.hh
#include "robot_behavior.hh"
class user_cell_state_impl
: public aether::entity_based_user_state_base<entt_store_traits> {
// ...
store_type store;
// our behaviour tree
robot_behavior rb;
public:
user_cell_state_impl(const aether_state_type &aether_state, const demo_parameters ¶ms) :
// ... other intialisers
rb(store, outgoing_message_buffer, aether_state.get_worker())
// ...
{ }
};
We keep track of an instance of
robot_behavior
in our cell state, and then in our cell tick:// ./Simulation/simulate.cc
void user_cell_state_impl::cell_tick(const aether_state_type &aether_state, float delta_time) {
// ... other systems
// tick our behaviour tree
rb.tick(delta_time);
}
we will make sure to "tick" our behavior tree.
You might notice we don't have any code that handles the case where the robot receives a coordinate to move to. The way we can handle this is either through another state in our behaviour tree, or with a dedicated system that receives messages and updates robot entities. The former would only work if the message is directed at a particular robot entity. The latter works better in cases where the cell needs to handle all messages in a separate system.
Notes:
- The XML document can be loaded via Aether'scapabilities. For the purposes of this tutorial we are simply hardcoding thedocument into our binary.
Integrating with BehaviorTree is rather straightforward, since it is really similar to how you would define a system with Aether and entt. If your usecase requires BehaviorTree, then following this tutorial will help you integrate behaviour trees very easily into your simulation. If, however, you don't need behaviour trees, consider using systems instead.