Bevy Components
In an entity component system (ECS) you can imagine that your rows are your entities and your components are your columns.
Components are what allow us to attach data to our entities.
An Entity
on its own is not very useful. It just represents a globally unique ID for some thing in our game.
So when we need to add health, or damage, or anything representing a state our entities can be in we do so by adding a component that holds the data.
Bevy uses it's own ECS to represent lots of its internals as components:
- Resources are just singleton components without an entity
- Your game window is a
Window
component. - Your cameras are some entity with a
Camera
component. - Handles are components we attach to entities
Most of the things you will be interested in will involve querying for one component or another through your systems.
Defining a component
We define components as normal structs, but we tell Bevy about them by using the derive macro:
#[derive(Component)]
struct Position {
x: i32,
y: i32
}
This derive macro will add behavior to this struct at compile time and make them available through our queries.
When we query for a component, we can ask for mutable or read-only access and then do whatever we want with its fields to change the state of our entities.
A Component
may be a struct but it can also be other data types like an enum or zero sized type:
// A simple marker type
#[derive(Component)]
struct Player;
// Or an enum
#[derive(Component)]
enum Ship {
Destroyer,
Frigate,
Scout
}
These kind of components without data act as indexes on our rows. They let us mark components as a type and then query for these in our systems.
Components are columns
A good mental model to have is that each of your components are the columns in your database, while the entities are your rows.
Thinking this way makes some important things more obvious:
- Each entity can only have one component of each type
- When you insert a component on an entity that already has one of that type, you will replace the original
So we can translate something like this:
fn spawn_entities(
mut commands: Commands
) {
commands.spawn((
Health(100.0),
Player,
Position { x: 3, y: 2 }
));
commands.spawn((
Health(72.0),
Enemy,
Position { x: 3, y: 1 }
));
}
As creating an imaginary database that looks like this:
Entity ID | Health | Player | Position | Enemy |
---|---|---|---|---|
1 | 100.0 | X | (3, 2) | |
2 | 72.0 | (3, 1) | X |
In reality, the components are stored as many separate contiguous arrays.
Each array has components of a single type. The Entity
is actually the index of all its components in each different array.
For example, imagine we have an array of Health
and Position
components:
Health: [Health(100.0), Health(72.0)]
Position: [Position(3, 2), Position(3, 1)]
If the entity's index is 0 then we can go into each array and access the components at that index, giving us Health(100.0)
and Position(3, 2)
This is why we can only have one component of each type. You can only have one component in the slot that matches our entity's index.
This is all done to boost performance by helping your CPU to more fetching of data from its cache.
Adding components to entities
We add components to entities through our commands:
fn spawn_player(
mut commands: Commands
) {
commands
.spawn_empty()
.insert(Player)
.insert(Ship::Destroyer)
.insert(Position { x: 1, y: 2 });
}
This schedules a series of commands to run that will add a new Entity
and then insert each component by placing it in the arrays we mentioned in the previous section.
These arrays can be one of two specialized types:
Table
-> Faster for iterating (the default)SparseSet
-> Faster for adding and removing
To use a SparseSet
we use an additional attribute when we derive our component:
#[derive(Component)]
#[component(storage = "SparseSet")]
struct Explode;
When to use one over the other? It depends on whether you will be querying or changing the components more.
Something like an Explode
component that is both used to trigger behavior and will be added and removed more than its read would be a great choice for a SparseSet
over a Table
.
When we want to get components for our entity we look up that index in each Table
or SparseSet
storing each different component. This is how our individual components end up being a part of one thing in our game world.
In reality there is some hidden complexity to make this an even faster process using archetypes which try and store groups of components together how they are most likely to be accessed.
Bevy uses these archetypes to determine which systems can run in parallel and other performance gains.
The ability to add or remove components from our entity actually comes from the Bundle
trait. All components implement this trait. When we derive our components, we have also implemented Bundle
.
In Bevy 0.15
there will be a newer feature that will be replacing bundles called required components which I will update here when they are released.
Using bundles
A Bundle
can hold a set of other components.
This is useful for when inserting components individually can be repetitive. Or we want to ensure all players are spawned with a certain set of components.
Its important to understand that a bundle does not actually exist. You cannot query for them. They are only used for adding groups of components and then they cease to exist.
Defining a bundle is just like our components. We use a derive macro:
#[derive(Bundle)]
struct PlayerBundle {
player: Player,
ship: Ship,
position: Position
}
Once derived, instead of inserting our individual components we can insert our bundle instead:
commands
.spawn_empty()
.insert(PlayerBundle{
player: Player,
ship: Ship::Destroyer,
position: Position { x: 1, y: 2 }
});
But its recommended to implement your own interfaces for creating our bundles to reduce the boilerplate:
impl PlayerBundle {
pub fn new(x: i32, y: i32) -> Self {
Self {
player: Player,
ship: Ship::Destroyer,
position: Position { x, y }
}
}
}
fn spawn_player_bundle(
mut commands: Commands
) {
commands
.spawn_empty()
.insert(PlayerBundle::new(1, 2));
}
Tuples of bundles also implement Bundle
(for tuples up to 15 bundles, sadly Rust is still missing variadic generics). This means we could have made a bundle of our components without defining a type:
fn spawn_player_tuple(
mut commands: Commands
) {
commands
.spawn_empty()
.insert((
Player,
Ship::Destroyer,
Position { x: 1, y: 2 }
));
}
Each Component
implements Bundle
and a tuple of bundles is also itself a Bundle
.
And taking it further, our tuples can contain nested tuples of bundles which allows us to nest our bundles to get around the 15 tuple limit.
A tuple of nothing ()
also counts as a bundle, which can be useful for spawning entities using World::spawn_batch
.
The component derive macro
Using #[derive(...)]
tells our compiler to generate code that will implement any traits we put within the parenthesis using their default implementation.
Default here means the implementation defined on the trait itself.
Not all Rust traits will define derivable default implementations. Something like Display
will be specific to each struct we put it onto. But for our components the trait itself is quite simple:
// https://docs.rs/bevy/latest/bevy/ecs/component/trait.Component.html
pub trait Component:
Send
+ Sync
+ 'static {
const STORAGE_TYPE: StorageType;
// Provided method
fn register_component_hooks(_hooks: &mut ComponentHooks) { ... }
}
Components have two types of storage with different trade offs:
TableStorage
(default): Fast for iteration, slow for adding/removingSparseSetStorage
: Fast for adding/removing, slow for iterating
Bevy is marking the trait as being Send + Sync + 'static
. These are trait bounds and they specify that any implementation of the Component
trait must also have these traits satisfied.
What Send + Sync
means is that our Component
is safe to pass between threads, which Bevy uses to run our systems in parallel. The 'static
is a lifetime which is saying that our Component
will live for the entire duration of our program.
This doesn't mean that components never get cleaned up, however. It just means that any reference to our component can live forever with the borrowing rules of Rust.