Matching
Queries match Entities.
Any Query who must say "I match Entities" is no true Query.
~ Erwin Canister
True, true... but perk up your ears!
Match Expressions are what makes fennecs performant and powerful! They are a complex topic, shaped by two intertwined ideas:
🅰️ the Match Type
, a somewhat commonplace concept in most ECS
Matching groups / selects Entities by the Components they must have, may have, or must not have.
Queries let us find and access any set of Entities & Components extremely quickly.
... and ...
🅱️ the Match Target
(or Match Relation
), a feature specific to fennecs
Targets further group entities by an optional secondary key, like an Object or another Entity.
Match Expressions with targets constitute many-to-1
Relations (Entity-Entity, or Entity-Object).
Relations can be backed by any Component type (and data) - even their Targets themselves or Shareable Components on them!
Both have low, usually zero cost - at runtime, as well as compile and design times.
In fact, the way matching is implemented is foundational for how Archetype-based ECS can be so fast, intuitive, and efficient.
Teaser
Together, Types
and Targets
unlock Entity relations and interactions in elegant, expressive ways that in many other ECS might seem like pure science fiction.
The 3-Body-Problem recipe and N-Body-Problem demo illustrate how to use Match Expressions to simulate complex systems of mutually interacting Entities.
How to Basic Match
A match expression is split into a Match Type
and a Target
(or Identity
), and they can be combined in any way to create complex Queries that match (and enumerate!) exactly the Entities and Components you need.
When building a Query with Match Expressions, any number of Match Expressions can be passed to the QueryBuilder to specify what this Query should include and exclude. Here's a quick example to give an overview over what's available
DARE TO PEEK: What an expressive smörgåsbord !
INVITE QUERY YOUR FRIENDS!
This query is a bit of a party invitation! It includes each entity with a Name component and a music PlayList (Name and PlayList become the output Stream Types of the query)
... but then it gets picky!
var partyGoers = world.Query<Name, PlayList>() // "()" means Match.Any
//... who is also a fox
.Has<Fox>()
//... and has a Friendship entity relation to both "you" & "me"
.Has<Friendship>(you)
.Has<Friendship>(me)
//... and who's not asleep
.Not<Sleeping>()
//... and has -either- a pet or a plush component
.Any<Pet>()
.Any<Plush>()
//... and has pizza
.Has<Pizza>()
//... but only if they specifically don't like (the string) pineapple
.Not<Likes>(Identity.Of("pineapple"));
// compile query to register it with the world
.Stream();
// WAIT! It's the 2020s. We need two last minute additions!
partyGoers.Subset<Vaccinated>(Match.Plain); // party is a safe space!
partyGoers.Exclude<Sick>(); // if that even needs saying
partyGoers.For((ref name, ref playlist) =>
{
DeeJay.Instruct($"{name} is coming, please play something from {playlist.entries}");
});
Match Types
From the start, a Query includes only Entities that match all of its Stream Types. It does so regardless of whether it's a Plain Component, a Entity-Entity Relation, or an Object Link - unless expressly specified for each stream type in the QueryBuilder factory method. (see: Match Targets
BEHIND THE SCENES: What does a Query even DO?
Each compiled Query object maintains a collection of all Archetypes it matches (and a filtered subset), and when iterating Components or enumerating Entities, the Query does so for each Archetype, in deterministic order.
Whenever a new Archetype materializes, the World will notify all matching Queries of its existence.
The Query feeds the Storages corresponding to its Stream Types of each matched Archetype to its Runner Delegates.
The three main Match Type
Expressions are:
Stream<>.Has<C>()
andStream<>.Has<C>(Identity)
includes only
Entities that have the given component or relation. MultipleHas
statements can be compared to a logicalA AND B AND C
.
Stream<>.Not<C>()
andStream<>.Not<C>(Identity)
excludes
any Entities that have the given component. MultipleNot
statements can be compared to a logicalNOT (A OR B OR C)
, aka.(NOT A) AND (NOT B) AND (NOT C)
.
Stream<>.Any<C>()
andStream<>.Any<C>(Identity)
matches Entities that match
at least one
of the Any statements. MultipleAny
statements can be compared to a logicalA OR B OR C
.
A Query being built is then further refined through Match Expressions passed to the builder's methods. These expressions are the building blocks of the query, and they can be combined in any way to create complex queries that match exactly the Entities you need.
BEHIND THE SCENES: Huh, Builders, Queries, confused yet?!
Technically, all of these are actually methods on QueryBuilder<>
instances, but in practice you almost never type the word QueryBuilder
. Instead, you acquire them via World.Query<>
. The builders expose a fluent interface to configure and then compile the query right away.
Match Targets
Not only the Stream Types can match specific or wildcarded targets. world.Query<ST1, ST2>()
is the same as world.Query<ST1, ST2>(Match.Any, Match.Any)
, but these targets can be other Wildcards, or the Identities of specific Objects and Entities.
The same is true for any other Match Type
expression, like Has
, Not
, or Any
.
THIS IS OUR MAIN TOOL
In ECS, the presence of another component often carries a meaning in itself, and Queries expose powerful, performant matching of Entities based on such presence.
The default
Identity, also known as Match.Plain
, matches only components that have no targets, i.e. that are just pure data without any relational meaning.
Wildcards
In addition to specific Entities
(or Object Identities) that can be Targets
, there are several virtual wildcard Identities:
Match.Any
matches any target, including the default.- this is the silent default for all Stream Types, unless otherwise specified
Match.Target
matches any actual targets (both Object and Entity, but not Plain)Match.Object
matches only Object targets (so called Object Links)Match.Entity
matches only Entity targets (so called Entity-Entity Relations)Match.Plain
matches only pure data components without targets.
Cleaning up unused Queries
Queries have a modest memory footprint, but in Worlds with many fragmented Archetypes, the cost of Query update notifications can add up! Call Query.Dispose()
to de-register a query from its World and free its resources.
Conflicting Match Expressions
Some expressions can cause a Query to be empty, or to miss matches you may want.
QueryBuilders start in checked mode: An internal safety flag tells them to throw exceptions whenever conflicting Match Expressions are incorporated that would lead to a guaranteed empty query.
As of fennecs 0.5.1, this safety feature is removed (it may come back, yet! feedback is welcome!).
Here's what's at risk:
Examples of Conflicts
var enemies = world.Query<Enemy>()
// query always empty, never possible to match
.Has<Position>().Not<Position>()`
// Match.Target includes Object & Entity, but we exclude Entity: only Object Links match!
.Has<Objective>(Match.Target).Not<Objective>(Match.Entity)
.Compile();
Query vs. Filter
Sometimes, it is useful to narrow down a Query a little (or a lot).
For example:
- perform an action on all followers of a specific character
- pre-fetch a certain piece of data and then pass it in as a Uniform to a Query Runner
PAWS FOR THOUGHT: When does the matching / filtering happen?
Exclusion criteria like those below are hard-baked into the query. This is slightly more performant, but semantically inflexible.
var friendsInNeed = world.Query<Friend>()
.Has<Owes>(Match.Entity) // we care about Entity-Entity relations
.Unchecked() // because the next two conflict with Entity
.Has<Owes>(bob) // subset - specifically anyone who owes bob
.Not<Owes>(me) // exclusion - but who does not owe myself
.Stream();
friendsInNeed.For(PayOffDebt);
What if bob despawns? In that case, a whole new query needs to be compiled (this is code you need to write), and any systems that use the query just would need to be notified of bob's death or its implications!
As a rule of paw, consider Filters first for these cases. A Filter has similar performance characteristics to a compiled (and thus immutable) query, and can be dynamically reconfigured!
PROTIP: SUBSET and EXCLUDE via Stream Filters
Our Query is always valid as compiled, and we can dynamically narrow down its matched archetypes whenever our criteria change.
var friendsInNeed = world.Query<Friend>()
.Has<Owes>(Match.Entity) // we care about Entity-Entity relations
.Stream();
friendsInNeed.Subset<Owes>(bob);
friendsInNeed.Exclude<Owes>(me);
// do our thing, pay their debt with bob
friendsInNeed.For(PayOffDebt);
// optional: restore or change when criteria change
friendsInNeed.ClearFilters();
If needed, the filter state can stick around forever (at a minimal cost per query whenever new relations create new archetypes).
This mechanism is great when the number of different relations stays relatively constant during the lifetime of the query; if each frame or tick several different Owes => Entity
relations are added or removed, then the broader query will get updated under the hood each time that happens.
It's also great if there are many Entities that have the same relation, because it makes iterating the query faster than having a query that spans various fragmented, tiny archetypes. However, if there are only very few Entities (let's say in the dozens), then the performance impact would be negligible.