As I sketched in a previous answer, the way I'd approach this would be to...
Register each needed query at system start-up (ie. once)
Store the cached query as an object containing a list of matching archetypes
Add matching archetypes to the query only at registration or when a new archetype is instantiated
That means that however expensive the matching might be, it does not occur every frame (or every system evaluation within a frame) - all that work happens at game boot or level load, or on the rare occasion where you need to spawn an object from a never-seen-before archetype.
All the high-frequency iteration happens over the cached list of matching archetypes, so there's no search/matching workload there.
public static class QueryManager {
// I used a SortedSet here so you can get O(log n) lookups
// and insertion, since your bit sets can be ordered
// lexicographically for a binary search via Query.CompareTo()
static SortedSet<Query> _queryCache = new();
static List<Archetype> _archetypes = new();
static Query _tempQuery;
// When a new query is registered, cache all matching
// archetypes known at that time and return the cached query.
// Cached queries are shared/de-duplicated between multiple requests.
public static Query RegisterQuery(ComponentSignature components) {
_tempQuery.signature = components;
if (!_queryCache.TryGetValue(_tempQuery, out Query cached)) {
_tempQuery.matches = new List<Archetype>();
foreach(var archetype in _archetypes) {
if (_tempQuery.Matches(archetype)) {
_tempQuery.matches.Add(archetype);
}
}
cached = _tempQuery;
_queryCache.Add(cached);
// Set aside a fresh temporary for the next attempt,
// since our old one got promoted to a permanent cached version.
_tempQuery = new Query();
}
return cached;
}
// When a new archetype is registered, add it to all
// cached queries that match it.
public static void RegisterArchetype(Archetype archetype) {
_archetypes.Add(archetype);
foreach(var query in _queryCache) {
if (query.Matches(archetype)) {
query.matches.Add(archetype);
}
}
}
}
Your system might then look something like...
public class FallingSystem {
Query _positionAndFallComponents;
public void Initialize() {
// Potentially expensive matching, done only when initializing:
_positionAndFallComponents = QueryManager.RegisterQuery(
new ComponentSignature(
typeof(PositionComponent),
typeof(FallComponent)
));
}
public void Update(float timeStep) {
// Within a frame, we can iterate the query immediately,
// without paying for any matching logic per use.
foreach(var archetype in _positionAndFallComponents.matches) {
var positions = archetype.GetComponents<PositionComponent>();
var falls = archetype.GetComponents<FallComponent>();
int count = archetype.EntityCount;
for (int i = 0; i < count; i++) {
positions[i].y -= falls[i].speed * timeStep;
}
}
}
}
(And the archetypes could be similar to the ones I sketch in this answer)