Repository: mlange-42/arche
Pull request #288 (2023-05-10 20:23:36)

Optimize query creation and cached queries
* Step back to caching archetypes instead of nodes for filters
* Remove argument `target` from `Filter.Matches`
* Get rid of interface for query archetype list, for faster query construction

coveralls: ## Pull Request Test Coverage Report for [Build 4941004995](https://coveralls.io/builds/59835759)

* **136** of **137**   **(99.27%)**  changed or added relevant lines in **9** files are covered.
* **10** unchanged lines in **1** file lost coverage.
* Overall coverage decreased (**-0.3%**) to **99.708%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [ecs/cache.go](https://coveralls.io/builds/59835759/source?filename=ecs%2Fcache.go#L113) | 14 | 15 | 93.33%
<!-- | **Total:** | **136** | **137** | **99.27%** | -->

|  Files with Coverage Reduction | New Missed Lines | % |
| :-----|--------------|--: |
| [ecs/world.go](https://coveralls.io/builds/59835759/source?filename=ecs%2Fworld.go#L1345) | 10 | 98.88% |
<!-- | **Total:** | **10** |  | -->

|  Totals | [![Coverage Status](https://coveralls.io/builds/59835759/badge)](https://coveralls.io/builds/59835759) |
| :-- | --: |
| Change from base [Build 4940120997](https://coveralls.io/builds/59832462): |  -0.3% |
| Covered Lines: | 3753 |
| Relevant Lines: | 3764 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 05bb5b7..c3b307e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,8 +35,8 @@
 * Optimize `RelationFilter`: get archetype directly instead of iterating complete node (#251)
 * Cached filters use swap-remove when removing an archetype (#253)
 * Speed up generic query re-compilation after changing the relation target (#255)
-* Speed up archetype and node iteration to be as fast as before the new nested structure (#270)
-* Filter cache stores archetype graph nodes instead of archetypes (#276)
+* Speed up archetype and node iteration to be as fast as before the new nested structure (#270, #288)
+* ~~Filter cache stores archetype graph nodes instead of archetypes (#276)~~ (#288)
 * Use `uint32` instead of `uintptr` for indices and query iteration counter (#283)
 
 ### Documentation
diff --git a/benchmark/competition/relations/plot.py b/benchmark/competition/relations/plot.py
index 15aaba0..c7d1264 100644
--- a/benchmark/competition/relations/plot.py
+++ b/benchmark/competition/relations/plot.py
@@ -24,6 +24,7 @@ if __name__ == "__main__":
     data = data[data["Entities"] > 1000]
 
     models = ["ParentList", "ParentSlice", "Child", "Default", "Cached"]
+    entities = np.unique(data["Entities"])
     parents = np.unique(data["Parents"])
     children = np.unique(data["Children"])
 
@@ -44,51 +45,55 @@ if __name__ == "__main__":
     plt.rcParams["svg.fonttype"] = "none"
     plt.rcParams["font.family"] = "Arial"
 
-    fig, ax = plt.subplots(figsize=(6, 4))
-    ax.set_title("Iter & get 16 byte")
-    ax.set_xscale("log")
-    ax.set_yscale("log")
-    ax.set_xticks([10, 100, 1000, 10000])
-    ax.set_xticklabels([10, 100, 1000, 10000])
-    ax.set_yticks([1, 2, 5, 10, 20, 50, 100, 200])
-    ax.set_yticklabels([1, 2, 5, 10, 20, 50, 100, 200])
-    ax.set_xlabel("Number of parents", fontsize=11)
-    ax.set_ylabel("Time per Entity [ns]", fontsize=11)
+    for column, title, loc in [
+        ("Parents", "Parent entities", 2),
+        ("Children", "Children per parent", 1),
+    ]:
+        fig, ax = plt.subplots(figsize=(6, 4))
+        ax.set_title("Benchmarks of ways to represent relations")
+        ax.set_xscale("log")
+        ax.set_yscale("log")
+        ax.set_xticks([10, 100, 1000, 10000])
+        ax.set_xticklabels([10, 100, 1000, 10000])
+        ax.set_yticks([1, 2, 5, 10, 20, 50, 100, 200])
+        ax.set_yticklabels([1, 2, 5, 10, 20, 50, 100, 200])
+        ax.set_xlabel(title, fontsize=11)
+        ax.set_ylabel("Time per Entity [ns]", fontsize=11)
 
-    ax.set_ylim(1, np.max(data["Time"]) * 1.2)
+        ax.set_ylim(1, np.max(data["Time"]) * 1.2)
 
-    for model in models:
-        mod_data = data[(data["Model"] == model)]
-        entities = np.unique(mod_data["Entities"])
-        for ent in entities:
-            extr = mod_data[mod_data["Entities"] == ent]
-            extr = extr.groupby("Parents").mean()
+        for model in models:
+            mod_data = data[(data["Model"] == model)]
+            count = np.unique(mod_data["Entities"])
+            for ent in count:
+                extr = mod_data[mod_data["Entities"] == ent]
+                extr = extr.groupby(column).mean()
 
+                line = linesEntities[ent]
+                ax.plot(
+                    extr.index,
+                    extr["Time"],
+                    linestyle=line[0],
+                    linewidth=line[1],
+                    color=colors[model],
+                    marker="o",
+                    markersize=3,
+                    label=model if ent == 100000 else None,
+                )
+
+        for ent in reversed(entities):
             line = linesEntities[ent]
             ax.plot(
-                extr.index,
-                extr["Time"],
+                [0],
+                [0],
                 linestyle=line[0],
                 linewidth=line[1],
-                color=colors[model],
-                marker="o",
-                markersize=3,
-                label=model if ent == 100000 else None,
+                color="black",
+                label=f"{ent//1000}k entities",
             )
-    
-    for ent in reversed(entities):
-        line = linesEntities[ent]
-        ax.plot(
-            [0],
-            [0],
-            linestyle=line[0],
-            linewidth=line[1],
-            color="black",
-            label=f"{ent//1000}k entities",
-        )
+        ax.legend(loc=loc)
 
-    ax.legend()
+        fig.tight_layout()
+        fig.savefig(f"results-{column}.svg")
 
-    fig.tight_layout()
-    fig.savefig("results.svg")
     plt.show()
diff --git a/benchmark/profile/relations/main.go b/benchmark/profile/relations/main.go
new file mode 100644
index 0000000..addbd47
--- /dev/null
+++ b/benchmark/profile/relations/main.go
@@ -0,0 +1,110 @@
+package main
+
+// Profiling:
+// go build ./benchmark/profile/relations
+// relations
+// go tool pprof -http=":8000" -nodefraction=0.001 relations cpu.pprof
+// go tool pprof -http=":8000" -nodefraction=0.001 relations mem.pprof
+
+import (
+	"math/rand"
+
+	"github.com/mlange-42/arche/ecs"
+	"github.com/mlange-42/arche/generic"
+	"github.com/pkg/profile"
+)
+
+// ParentList component
+type ParentList struct {
+	FirstChild ecs.Entity
+	Value      int
+}
+
+// ChildRelation component
+type ChildRelation struct {
+	ecs.Relation
+	Value int
+}
+
+func main() {
+
+	count := 10
+	iters := 1000
+	parents := 10000
+	children := 1
+
+	stop := profile.Start(profile.CPUProfile, profile.ProfilePath("."))
+	run(count, iters, parents, children)
+	stop.Stop()
+
+	stop = profile.Start(profile.MemProfileAllocs, profile.ProfilePath("."))
+	run(count, iters, parents, children)
+	stop.Stop()
+}
+
+func run(rounds, iters, numParents, numChildren int) {
+	for i := 0; i < rounds; i++ {
+		world := ecs.NewWorld(ecs.NewConfig().WithCapacityIncrement(1024))
+		parentID := ecs.ComponentID[ParentList](&world)
+		childID := ecs.ComponentID[ChildRelation](&world)
+
+		parentMapper := generic.NewMap1[ParentList](&world)
+		childMapper := generic.NewMap1[ChildRelation](&world, generic.T[ChildRelation]())
+		targetMapper := generic.NewMap[ChildRelation](&world)
+
+		spawnedPar := parentMapper.NewQuery(numParents)
+		parents := make([]ecs.Entity, 0, numParents)
+		for spawnedPar.Next() {
+			parents = append(parents, spawnedPar.Entity())
+		}
+
+		spawnedChild := childMapper.NewQuery(numParents * numChildren)
+		children := make([]ecs.Entity, 0, numParents*numChildren)
+		for spawnedChild.Next() {
+			children = append(children, spawnedChild.Entity())
+		}
+		rand.Shuffle(len(children), func(i, j int) { children[i], children[j] = children[j], children[i] })
+
+		for i, e := range children {
+			child := childMapper.Get(e)
+			child.Value = 1
+			parent := parents[i/numChildren]
+			targetMapper.SetRelation(e, parent)
+		}
+
+		parentFilter := ecs.All(parentID)
+		cf := world.Cache().Register(parentFilter)
+
+		var childF ecs.Filter = ecs.All(childID)
+		chf := ecs.RelationFilter{
+			Filter: childF,
+			Target: ecs.Entity{},
+		}
+
+		for i := 0; i < iters; i++ {
+			query := world.Query(&cf)
+			for query.Next() {
+				parData := (*ParentList)(query.Get(parentID))
+				par := query.Entity()
+
+				chf.Target = par
+
+				childQuery := world.Query(&chf)
+				for childQuery.Next() {
+					child := (*ChildRelation)(childQuery.Get(childID))
+					parData.Value += child.Value
+				}
+			}
+		}
+
+		parQuery := world.Query(parentFilter)
+
+		expected := numChildren * iters
+		for parQuery.Next() {
+			par := (*ParentList)(parQuery.Get(parentID))
+			if par.Value != expected {
+				panic("wrong number of children")
+			}
+		}
+	}
+}
diff --git a/ecs/archetype_node.go b/ecs/archetype_node.go
index 85d71c3..f672bac 100644
--- a/ecs/archetype_node.go
+++ b/ecs/archetype_node.go
@@ -83,7 +83,7 @@ func newArchNode(mask Mask, data *nodeData, relation int8, capacityIncrement int
 // Matches the archetype node against a filter.
 // Ignores the relation target.
 func (a *archNode) Matches(f Filter) bool {
-	return f.Matches(a.Mask, nil)
+	return f.Matches(a.Mask)
 }
 
 // Archetypes of the node.
diff --git a/ecs/bitmask.go b/ecs/bitmask.go
index 967d955..0ef9655 100644
--- a/ecs/bitmask.go
+++ b/ecs/bitmask.go
@@ -30,7 +30,7 @@ func All(ids ...ID) Mask {
 }
 
 // Matches matches a filter against a bitmask.
-func (b Mask) Matches(bits Mask, relation *Entity) bool {
+func (b Mask) Matches(bits Mask) bool {
 	return bits.Contains(b)
 }
 
diff --git a/ecs/cache.go b/ecs/cache.go
index 09672cb..6223690 100644
--- a/ecs/cache.go
+++ b/ecs/cache.go
@@ -2,9 +2,9 @@ package ecs
 
 // Cache entry for a [Filter].
 type cacheEntry struct {
-	ID     uint32      // Filter ID.
-	Filter Filter      // The underlying filter.
-	Nodes  []*archNode // Nodes matching the filter.
+	ID         uint32              // Filter ID.
+	Filter     Filter              // The underlying filter.
+	Archetypes pointers[archetype] // Nodes matching the filter.
 }
 
 // Cache provides [Filter] caching to speed up queries.
@@ -22,10 +22,10 @@ type cacheEntry struct {
 //
 // The overhead of tracking cached filters internally is very low, as updates are required only when new archetypes are created.
 type Cache struct {
-	indices  map[uint32]int             // Mapping from filter IDs to indices in filters
-	filters  []cacheEntry               // The cached filters, indexed by indices
-	getNodes func(f Filter) []*archNode // Callback for getting archetypes for a new filter from the world
-	intPool  intPool[uint32]            // Pool for filter IDs
+	indices       map[uint32]int              // Mapping from filter IDs to indices in filters
+	filters       []cacheEntry                // The cached filters, indexed by indices
+	getArchetypes func(f Filter) []*archetype // Callback for getting archetypes for a new filter from the world
+	intPool       intPool[uint32]             // Pool for filter IDs
 }
 
 // newCache creates a new [Cache].
@@ -51,9 +51,9 @@ func (c *Cache) Register(f Filter) CachedFilter {
 	id := c.intPool.Get()
 	c.filters = append(c.filters,
 		cacheEntry{
-			ID:     id,
-			Filter: f,
-			Nodes:  c.getNodes(f),
+			ID:         id,
+			Filter:     f,
+			Archetypes: pointers[archetype]{c.getArchetypes(f)},
 		})
 	c.indices[id] = len(c.filters) - 1
 	return CachedFilter{f, id}
@@ -94,11 +94,49 @@ func (c *Cache) get(f *CachedFilter) *cacheEntry {
 // Adds a node.
 //
 // Iterates over all filters and adds the node to the resp. entry where the filter matches.
-func (c *Cache) addNode(node *archNode) {
+func (c *Cache) addArchetype(arch *archetype) {
+	if !arch.HasRelation() {
+		for i := range c.filters {
+			e := &c.filters[i]
+			if !e.Filter.Matches(arch.Mask) {
+				continue
+			}
+			e.Archetypes.Add(arch)
+		}
+		return
+	}
+
 	for i := range c.filters {
 		e := &c.filters[i]
-		if node.Matches(e.Filter) {
-			e.Nodes = append(e.Nodes, node)
+		if !e.Filter.Matches(arch.Mask) {
+			continue
+		}
+		if rf, ok := e.Filter.(*RelationFilter); ok {
+			if rf.Target == arch.RelationTarget {
+				e.Archetypes.Add(arch)
+			}
+			continue
+		}
+		e.Archetypes.Add(arch)
+	}
+}
+
+// Removes an archetype.
+//
+// Iterates over all filters and removes the archetype from the resp. entry where the filter matches.
+func (c *Cache) removeArchetype(arch *archetype) {
+	for i := range c.filters {
+		e := &c.filters[i]
+		if !e.Filter.Matches(arch.Mask) {
+			continue
+		}
+
+		if rf, ok := e.Filter.(*RelationFilter); ok {
+			if rf.Target == arch.RelationTarget {
+				e.Archetypes.Remove(arch)
+			}
+			continue
 		}
+		e.Archetypes.Remove(arch)
 	}
 }
diff --git a/ecs/filter.go b/ecs/filter.go
index c99c099..16477db 100644
--- a/ecs/filter.go
+++ b/ecs/filter.go
@@ -8,7 +8,7 @@ package ecs
 // For advanced filtering, see package [github.com/mlange-42/arche/filter].
 type Filter interface {
 	// Matches the filter against a bitmask, i.e. a component composition.
-	Matches(bits Mask, relation *Entity) bool
+	Matches(bits Mask) bool
 }
 
 // MaskFilter is a [Filter] for including and excluding certain components.
@@ -19,32 +19,32 @@ type MaskFilter struct {
 }
 
 // Matches matches a filter against a mask.
-func (f *MaskFilter) Matches(bits Mask, relation *Entity) bool {
+func (f *MaskFilter) Matches(bits Mask) bool {
 	return bits.Contains(f.Include) && (f.Exclude.IsZero() || !bits.ContainsAny(f.Exclude))
 }
 
 // RelationFilter is a [Filter] for a [Relation] target, in addition to components.
-type relationFilter struct {
+type RelationFilter struct {
 	Filter Filter // Components filter.
 	Target Entity // Relation target entity.
 }
 
-// RelationFilter creates a new [Relation] filter.
+// NewRelationFilter creates a new [Relation] filter.
 // It is a [Filter] for a [Relation] target, in addition to components.
 //
 // Logic filters ignore relation targets. Thus, a relation filter should be the outermost filter.
 //
 // See [Relation] for details and examples.
-func RelationFilter(filter Filter, target Entity) Filter {
-	return &relationFilter{
+func NewRelationFilter(filter Filter, target Entity) RelationFilter {
+	return RelationFilter{
 		Filter: filter,
 		Target: target,
 	}
 }
 
 // Matches matches a filter against a mask.
-func (f *relationFilter) Matches(bits Mask, relation *Entity) bool {
-	return f.Filter.Matches(bits, relation) && (relation == nil || f.Target == *relation)
+func (f *RelationFilter) Matches(bits Mask) bool {
+	return f.Filter.Matches(bits)
 }
 
 // CachedFilter is a filter that is cached by the world.
@@ -56,6 +56,6 @@ type CachedFilter struct {
 }
 
 // Matches matches a filter against a mask.
-func (f *CachedFilter) Matches(bits Mask, relation *Entity) bool {
-	return f.filter.Matches(bits, relation)
+func (f *CachedFilter) Matches(bits Mask) bool {
+	return f.filter.Matches(bits)
 }
diff --git a/ecs/query.go b/ecs/query.go
index 626bca3..346c86d 100644
--- a/ecs/query.go
+++ b/ecs/query.go
@@ -14,8 +14,9 @@ import (
 // For advanced filtering, see package [github.com/mlange-42/arche/filter].
 type Query struct {
 	filter         Filter           // The filter used by the query.
-	archetypes     archetypes       // The query's archetypes (can be all, unfiltered archetypes).
-	nodes          []*archNode      // The query's nodes
+	archetypes     []*archetype     // The query's filtered archetypes.
+	nodeArchetypes archetypes       // The query's archetypes of the current node.
+	nodes          []*archNode      // The query's nodes.
 	world          *World           // The [World].
 	access         *archetypeAccess // Access helper for the archetype currently being iterated.
 	entityIndex    uint32           // Iteration index of the current [Entity] current archetype.
@@ -29,7 +30,7 @@ type Query struct {
 }
 
 // newQuery creates a new Filter
-func newQuery(world *World, filter Filter, lockBit uint8, nodes []*archNode, isFiltered bool) Query {
+func newQuery(world *World, filter Filter, lockBit uint8, nodes []*archNode) Query {
 	return Query{
 		filter:     filter,
 		world:      world,
@@ -38,21 +39,36 @@ func newQuery(world *World, filter Filter, lockBit uint8, nodes []*archNode, isF
 		nodeIndex:  -1,
 		lockBit:    lockBit,
 		count:      -1,
-		isFiltered: isFiltered,
+		isFiltered: false,
+		isBatch:    false,
+	}
+}
+
+// newQuery creates a new Filter
+func newCachedQuery(world *World, filter Filter, lockBit uint8, archetypes []*archetype) Query {
+	return Query{
+		filter:     filter,
+		world:      world,
+		archetypes: archetypes,
+		archIndex:  -1,
+		nodeIndex:  -1,
+		lockBit:    lockBit,
+		count:      -1,
+		isFiltered: true,
 		isBatch:    false,
 	}
 }
 
 // newQuery creates a query on a single archetype
-func newArchQuery(world *World, lockBit uint8, archetype *batchArchetype) Query {
+func newBatchQuery(world *World, lockBit uint8, archetype *batchArchetype) Query {
 	arch := archetype.Archetype
 	if archetype.StartIndex > 0 {
 		return Query{
 			filter:         nil,
-			isFiltered:     true,
+			isFiltered:     false,
 			isBatch:        true,
 			world:          world,
-			archetypes:     archetype,
+			nodeArchetypes: archetype,
 			access:         &arch.archetypeAccess,
 			archIndex:      0,
 			lockBit:        lockBit,
@@ -62,14 +78,14 @@ func newArchQuery(world *World, lockBit uint8, archetype *batchArchetype) Query
 		}
 	}
 	return Query{
-		filter:     nil,
-		isFiltered: true,
-		isBatch:    true,
-		world:      world,
-		archetypes: archetype,
-		archIndex:  -1,
-		lockBit:    lockBit,
-		count:      int32(archetype.EndIndex),
+		filter:         nil,
+		isFiltered:     false,
+		isBatch:        true,
+		world:          world,
+		nodeArchetypes: archetype,
+		archIndex:      -1,
+		lockBit:        lockBit,
+		count:          int32(archetype.EndIndex),
 	}
 }
 
@@ -172,6 +188,9 @@ func (q *Query) Close() {
 
 // nextArchetype proceeds to the next archetype, and returns whether this was successful/possible.
 func (q *Query) nextArchetype() bool {
+	if q.isFiltered {
+		return q.nextArchetypeFiltered()
+	}
 	if q.isBatch {
 		return q.nextBatch()
 	}
@@ -187,52 +206,61 @@ func (q *Query) nextBatch() bool {
 }
 
 func (q *Query) nextArchetypeBatch() bool {
-	len := int32(q.archetypes.Len()) - 1
+	if q.archIndex >= 0 {
+		return false
+	}
+	q.archIndex++
+	a := q.nodeArchetypes.Get(q.archIndex)
+	q.access = &a.archetypeAccess
+	q.entityIndex = 0
+	batch := q.nodeArchetypes.(*batchArchetype)
+	q.entityIndexMax = batch.EndIndex - 1
+	return true
+}
+
+func (q *Query) nextArchetypeSimple() bool {
+	len := int32(q.nodeArchetypes.Len()) - 1
 	for q.archIndex < len {
 		q.archIndex++
-		a := q.archetypes.Get(q.archIndex)
-		if a.Len() > 0 {
-			q.access = &a.archetypeAccess
-			q.entityIndex = 0
-			batch := q.archetypes.(*batchArchetype)
-			q.entityIndexMax = batch.EndIndex - 1
-			return true
+		a := q.nodeArchetypes.Get(q.archIndex)
+		aLen := a.Len()
+		if aLen == 0 {
+			continue
 		}
+		q.access = &a.archetypeAccess
+		q.entityIndex = 0
+		q.entityIndexMax = aLen - 1
+		return true
 	}
 	return false
 }
 
-func (q *Query) nextArchetypeSimple() bool {
-	len := int32(q.archetypes.Len()) - 1
+func (q *Query) nextArchetypeFiltered() bool {
+	len := int32(len(q.archetypes) - 1)
 	for q.archIndex < len {
 		q.archIndex++
-		a := q.archetypes.Get(q.archIndex)
+		a := q.archetypes[q.archIndex]
 		aLen := a.Len()
-		if aLen > 0 {
-			q.access = &a.archetypeAccess
-			q.entityIndex = 0
-			q.entityIndexMax = aLen - 1
-			return true
+		if aLen == 0 {
+			continue
 		}
+		q.access = &a.archetypeAccess
+		q.entityIndex = 0
+		q.entityIndexMax = aLen - 1
+		return true
 	}
+	q.world.closeQuery(q)
 	return false
 }
 
 func (q *Query) nextNodeOrArchetype() bool {
-	if q.archetypes != nil && q.nextArchetypeSimple() {
+	if q.nodeArchetypes != nil && q.nextArchetypeSimple() {
 		return true
 	}
 	return q.nextNode()
 }
 
 func (q *Query) nextNode() bool {
-	if q.isFiltered {
-		return q.nextNodeAll()
-	}
-	return q.nextNodeFilter()
-}
-
-func (q *Query) nextNodeFilter() bool {
 	len := int32(len(q.nodes)) - 1
 	for q.nodeIndex < len {
 		q.nodeIndex++
@@ -259,45 +287,7 @@ func (q *Query) nextNodeFilter() bool {
 			continue
 		}
 
-		if rf, ok := q.filter.(*relationFilter); ok {
-			target := rf.Target
-			if arch, ok := n.archetypeMap[target]; ok && arch.Len() > 0 {
-				q.setArchetype(nil, &arch.archetypeAccess, arch.index, arch.Len()-1)
-				return true
-			}
-			continue
-		}
-
-		q.setArchetype(arches, nil, -1, 0)
-		if q.nextArchetypeSimple() {
-			return true
-		}
-	}
-	q.archetypes = nil
-	q.world.closeQuery(q)
-	return false
-}
-
-func (q *Query) nextNodeAll() bool {
-	len := int32(len(q.nodes)) - 1
-	for q.nodeIndex < len {
-		q.nodeIndex++
-		n := q.nodes[q.nodeIndex]
-		arches := n.Archetypes()
-
-		if !n.HasRelation {
-			// There should be at least one archetype.
-			// Otherwise, the node would be inactive.
-			arch := arches.Get(0)
-			archLen := arch.Len()
-			if archLen > 0 {
-				q.setArchetype(nil, &arch.archetypeAccess, arch.index, archLen-1)
-				return true
-			}
-			continue
-		}
-
-		if rf, ok := q.filter.(*relationFilter); ok {
+		if rf, ok := q.filter.(*RelationFilter); ok {
 			target := rf.Target
 			if arch, ok := n.archetypeMap[target]; ok && arch.Len() > 0 {
 				q.setArchetype(nil, &arch.archetypeAccess, arch.index, arch.Len()-1)
@@ -311,13 +301,13 @@ func (q *Query) nextNodeAll() bool {
 			return true
 		}
 	}
-	q.archetypes = nil
+	q.nodeArchetypes = nil
 	q.world.closeQuery(q)
 	return false
 }
 
 func (q *Query) setArchetype(arches archetypes, access *archetypeAccess, archIndex int32, maxIndex uint32) {
-	q.archetypes = arches
+	q.nodeArchetypes = arches
 	q.archIndex = archIndex
 	q.access = access
 	q.entityIndex = 0
@@ -333,12 +323,19 @@ func (q *Query) stepArchetype(step uint32) (int, bool) {
 }
 
 func (q *Query) countEntities() int {
-	// This is not necessary as batch queries get their count upon construction.
-	// if q.isBatch {}
-
 	var count uint32 = 0
+
+	if q.isFiltered {
+		ln := int32(len(q.archetypes))
+		var i int32
+		for i = 0; i < ln; i++ {
+			count += q.archetypes[i].Len()
+		}
+		return int(count)
+	}
+
 	for _, nd := range q.nodes {
-		if !nd.IsActive || (!q.isFiltered && !nd.Matches(q.filter)) {
+		if !nd.IsActive || !nd.Matches(q.filter) {
 			continue
 		}
 
@@ -351,7 +348,7 @@ func (q *Query) countEntities() int {
 			continue
 		}
 
-		if rf, ok := q.filter.(*relationFilter); ok {
+		if rf, ok := q.filter.(*RelationFilter); ok {
 			target := rf.Target
 			if arch, ok := nd.archetypeMap[target]; ok {
 				count += arch.Len()
diff --git a/ecs/world.go b/ecs/world.go
index 79c9566..45f5088 100644
--- a/ecs/world.go
+++ b/ecs/world.go
@@ -278,7 +278,7 @@ func (w *World) newEntities(count int, targetID int8, target Entity, comps ...ID
 func (w *World) newEntitiesQuery(count int, targetID int8, target Entity, comps ...ID) Query {
 	arch, startIdx := w.newEntitiesNoNotify(count, targetID, target, comps...)
 	lock := w.lock()
-	return newArchQuery(w, lock, &batchArchetype{arch, startIdx, arch.Len(), nil, arch.Components(), nil})
+	return newBatchQuery(w, lock, &batchArchetype{arch, startIdx, arch.Len(), nil, arch.Components(), nil})
 }
 
 // Creates new entities with component values without returning a query over them.
@@ -314,7 +314,7 @@ func (w *World) newEntitiesWithQuery(count int, targetID int8, target Entity, co
 
 	arch, startIdx := w.newEntitiesWithNoNotify(count, targetID, target, ids, comps...)
 	lock := w.lock()
-	return newArchQuery(w, lock, &batchArchetype{arch, startIdx, arch.Len(), nil, arch.Components(), nil})
+	return newBatchQuery(w, lock, &batchArchetype{arch, startIdx, arch.Len(), nil, arch.Components(), nil})
 }
 
 // RemoveEntity removes and recycles an [Entity].
@@ -669,7 +669,7 @@ func (w *World) exchangeBatch(filter Filter, add []ID, rem []ID, callback func(Q
 			}
 		} else {
 			lock := w.lock()
-			query := newArchQuery(w, lock, &batchArchetype{newArch, start, newArch.Len(), arch, add, rem})
+			query := newBatchQuery(w, lock, &batchArchetype{newArch, start, newArch.Len(), arch, add, rem})
 			callback(query)
 			w.checkLocked()
 		}
@@ -824,7 +824,7 @@ func (w *World) setRelationBatch(filter Filter, comp ID, target Entity, callback
 			}
 		} else {
 			lock := w.lock()
-			query := newArchQuery(w, lock, &batchArchetype{newArch, start, end, arch, nil, nil})
+			query := newBatchQuery(w, lock, &batchArchetype{newArch, start, end, arch, nil, nil})
 			callback(query)
 			w.checkLocked()
 		}
@@ -940,11 +940,10 @@ func (w *World) Reset() {
 func (w *World) Query(filter Filter) Query {
 	l := w.lock()
 	if cached, ok := filter.(*CachedFilter); ok {
-		nodes := w.filterCache.get(cached).Nodes
-		return newQuery(w, cached.filter, l, nodes, true)
+		return newCachedQuery(w, cached.filter, l, w.filterCache.get(cached).Archetypes.pointers)
 	}
 
-	return newQuery(w, filter, l, w.nodePointers, false)
+	return newQuery(w, filter, l, w.nodePointers)
 }
 
 // Resources of the world.
@@ -958,8 +957,8 @@ func (w *World) Resources() *Resources {
 //
 // See [Cache] for details on filter caching.
 func (w *World) Cache() *Cache {
-	if w.filterCache.getNodes == nil {
-		w.filterCache.getNodes = w.getNodes
+	if w.filterCache.getArchetypes == nil {
+		w.filterCache.getArchetypes = w.getArchetypes
 	}
 	return &w.filterCache
 }
@@ -1285,7 +1284,6 @@ func (w *World) createArchetypeNode(mask Mask, relation int8) *archNode {
 	w.relationNodes = append(w.relationNodes, nd)
 	w.nodePointers = append(w.nodePointers, nd)
 
-	w.filterCache.addNode(nd)
 	return nd
 }
 
@@ -1303,28 +1301,25 @@ func (w *World) createArchetype(node *archNode, target Entity, forStorage bool)
 		arch.Init(node, archIndex, forStorage, target)
 		node.SetArchetype(arch)
 	}
+	w.filterCache.addArchetype(arch)
 	return arch
 }
 
 // Returns all archetypes that match the given filter. Used by [Cache].
 func (w *World) getArchetypes(filter Filter) []*archetype {
-	arches := []*archetype{}
-
-	var nodes []*archNode
-	filtered := false
 	if cached, ok := filter.(*CachedFilter); ok {
-		nodes = w.filterCache.get(cached).Nodes
-		filtered = true
-	} else {
-		nodes = w.nodePointers
+		return w.filterCache.get(cached).Archetypes.pointers
 	}
 
+	arches := []*archetype{}
+	nodes := w.nodePointers
+
 	for _, nd := range nodes {
-		if !nd.IsActive || (!filtered && !nd.Matches(filter)) {
+		if !nd.IsActive || !nd.Matches(filter) {
 			continue
 		}
 
-		if rf, ok := filter.(*relationFilter); ok {
+		if rf, ok := filter.(*RelationFilter); ok {
 			target := rf.Target
 			if arch, ok := nd.archetypeMap[target]; ok {
 				arches = append(arches, arch)
@@ -1346,23 +1341,6 @@ func (w *World) getArchetypes(filter Filter) []*archetype {
 	return arches
 }
 
-// Returns all nodes that match the given filter. Used by [Cache].
-func (w *World) getNodes(filter Filter) []*archNode {
-	nodes := []*archNode{}
-	ln := w.nodes.Len()
-	var i int32
-	for i = 0; i < ln; i++ {
-		nd := w.nodes.Get(i)
-		if !nd.IsActive || !nd.Matches(filter) {
-			continue
-		}
-
-		nodes = append(nodes, nd)
-	}
-
-	return nodes
-}
-
 // Removes the archetype if it is empty, and has a relation to a dead target.
 func (w *World) cleanupArchetype(arch *archetype) {
 	if arch.Len() > 0 || !arch.node.HasRelation {
@@ -1388,6 +1366,7 @@ func (w *World) cleanupArchetypes(target Entity) {
 // Removes/da-activates a relation archetype.
 func (w *World) removeArchetype(arch *archetype) {
 	arch.node.RemoveArchetype(arch)
+	w.Cache().removeArchetype(arch)
 }
 
 // componentID returns the ID for a component type, and registers it if not already registered.
@@ -1407,7 +1386,7 @@ func (w *World) closeQuery(query *Query) {
 	w.unlock(query.lockBit)
 
 	if w.listener != nil {
-		if arch, ok := query.archetypes.(*batchArchetype); ok {
+		if arch, ok := query.nodeArchetypes.(*batchArchetype); ok {
 			w.notifyQuery(arch)
 		}
 	}
diff --git a/examples/relations/main.go b/examples/relations/main.go
index 537fbac..ccc996c 100644
--- a/examples/relations/main.go
+++ b/examples/relations/main.go
@@ -45,10 +45,10 @@ func run() {
 	childBuilder.NewBatch(10, parent2)
 
 	// Create a filter for a relation target.
-	filter := ecs.RelationFilter(ecs.All(childID), parent1)
+	filter := ecs.NewRelationFilter(ecs.All(childID), parent1)
 
 	// Create a query from it.
-	query := world.Query(filter)
+	query := world.Query(&filter)
 
 	// Iterate the query.
 	for query.Next() {
diff --git a/filter/filter.go b/filter/filter.go
index 8069760..e4eb0ae 100644
--- a/filter/filter.go
+++ b/filter/filter.go
@@ -20,7 +20,7 @@ func Any(comps ...ecs.ID) ANY {
 }
 
 // Matches matches a filter against a bitmask
-func (f ANY) Matches(bits ecs.Mask, target *ecs.Entity) bool {
+func (f ANY) Matches(bits ecs.Mask) bool {
 	return bits.ContainsAny(ecs.Mask(f))
 }
 
@@ -33,7 +33,7 @@ func NoneOf(comps ...ecs.ID) NoneOF {
 }
 
 // Matches matches a filter against a bitmask
-func (f NoneOF) Matches(bits ecs.Mask, target *ecs.Entity) bool {
+func (f NoneOF) Matches(bits ecs.Mask) bool {
 	return !bits.ContainsAny(ecs.Mask(f))
 }
 
@@ -46,7 +46,7 @@ func AnyNot(comps ...ecs.ID) AnyNOT {
 }
 
 // Matches matches a filter against a bitmask
-func (f AnyNOT) Matches(bits ecs.Mask, target *ecs.Entity) bool {
+func (f AnyNOT) Matches(bits ecs.Mask) bool {
 	return !bits.Contains(ecs.Mask(f))
 }
 
@@ -66,8 +66,8 @@ func And(l, r ecs.Filter) *AND {
 }
 
 // Matches matches a filter against a bitmask.
-func (f *AND) Matches(bits ecs.Mask, target *ecs.Entity) bool {
-	return f.L.Matches(bits, nil) && f.R.Matches(bits, nil)
+func (f *AND) Matches(bits ecs.Mask) bool {
+	return f.L.Matches(bits) && f.R.Matches(bits)
 }
 
 // OR combines two filters using OR.
@@ -86,8 +86,8 @@ func Or(l, r ecs.Filter) *OR {
 }
 
 // Matches matches a filter against a bitmask.
-func (f *OR) Matches(bits ecs.Mask, target *ecs.Entity) bool {
-	return f.L.Matches(bits, nil) || f.R.Matches(bits, nil)
+func (f *OR) Matches(bits ecs.Mask) bool {
+	return f.L.Matches(bits) || f.R.Matches(bits)
 }
 
 // XOR combines two filters using XOR.
@@ -106,8 +106,8 @@ func XOr(l, r ecs.Filter) *XOR {
 }
 
 // Matches matches a filter against a bitmask.
-func (f *XOR) Matches(bits ecs.Mask, target *ecs.Entity) bool {
-	return f.L.Matches(bits, nil) != f.R.Matches(bits, nil)
+func (f *XOR) Matches(bits ecs.Mask) bool {
+	return f.L.Matches(bits) != f.R.Matches(bits)
 }
 
 // NOT inverts a filter. It matches if the inner filter does not.
@@ -123,6 +123,6 @@ func Not(f ecs.Filter) *NOT {
 }
 
 // Matches matches a filter against a bitmask.
-func (f *NOT) Matches(bits ecs.Mask, target *ecs.Entity) bool {
-	return !f.F.Matches(bits, target)
+func (f *NOT) Matches(bits ecs.Mask) bool {
+	return !f.F.Matches(bits)
 }
diff --git a/generic/compiled.go b/generic/compiled.go
index fcf3442..968050c 100644
--- a/generic/compiled.go
+++ b/generic/compiled.go
@@ -12,6 +12,7 @@ var relationType = reflect.TypeOf((*ecs.Relation)(nil)).Elem()
 // compiledQuery is a helper for compiling a generic filter into a [ecs.Filter].
 type compiledQuery struct {
 	maskFilter     ecs.MaskFilter
+	relationFilter ecs.RelationFilter
 	cachedFilter   ecs.CachedFilter
 	filter         ecs.Filter
 	Ids            []ecs.ID
@@ -65,7 +66,8 @@ func (q *compiledQuery) Compile(w *ecs.World, include, optional, exclude []Comp,
 		if hasTarget {
 			q.HasTarget = true
 			q.Target = target
-			q.filter = ecs.RelationFilter(&q.maskFilter, target)
+			q.relationFilter = ecs.NewRelationFilter(&q.maskFilter, target)
+			q.filter = &q.relationFilter
 		} else {
 			q.filter = &q.maskFilter
 		}
diff --git a/generic/generate/query.go.txt b/generic/generate/query.go.txt
index 96dd2c3..df008c3 100644
--- a/generic/generate/query.go.txt
+++ b/generic/generate/query.go.txt
@@ -106,7 +106,9 @@ func (q *Filter{{ .Index }}{{ .Types }}) Query(w *ecs.World, target ...ecs.Entit
 		if q.hasTarget {
 			panic("can't change relation target on a query with fixed target")
 		}
-		filter = ecs.RelationFilter(&q.compiled.maskFilter, target[0])
+		q.compiled.relationFilter.Filter = &q.compiled.maskFilter
+		q.compiled.relationFilter.Target = target[0]
+		filter = &q.compiled.relationFilter
 	}
 
 	return Query{{ .Index }}{{ .Types }}{
diff --git a/generic/query_generated.go b/generic/query_generated.go
index d93468d..28e7393 100644
--- a/generic/query_generated.go
+++ b/generic/query_generated.go
@@ -94,7 +94,9 @@ func (q *Filter0) Query(w *ecs.World, target ...ecs.Entity) Query0 {
 		if q.hasTarget {
 			panic("can't change relation target on a query with fixed target")
 		}
-		filter = ecs.RelationFilter(&q.compiled.maskFilter, target[0])
+		q.compiled.relationFilter.Filter = &q.compiled.maskFilter
+		q.compiled.relationFilter.Target = target[0]
+		filter = &q.compiled.relationFilter
 	}
 
 	return Query0{
@@ -256,7 +258,9 @@ func (q *Filter1[A]) Query(w *ecs.World, target ...ecs.Entity) Query1[A] {
 		if q.hasTarget {
 			panic("can't change relation target on a query with fixed target")
 		}
-		filter = ecs.RelationFilter(&q.compiled.maskFilter, target[0])
+		q.compiled.relationFilter.Filter = &q.compiled.maskFilter
+		q.compiled.relationFilter.Target = target[0]
+		filter = &q.compiled.relationFilter
 	}
 
 	return Query1[A]{
@@ -428,7 +432,9 @@ func (q *Filter2[A, B]) Query(w *ecs.World, target ...ecs.Entity) Query2[A, B] {
 		if q.hasTarget {
 			panic("can't change relation target on a query with fixed target")
 		}
-		filter = ecs.RelationFilter(&q.compiled.maskFilter, target[0])
+		q.compiled.relationFilter.Filter = &q.compiled.maskFilter
+		q.compiled.relationFilter.Target = target[0]
+		filter = &q.compiled.relationFilter
 	}
 
 	return Query2[A, B]{
@@ -604,7 +610,9 @@ func (q *Filter3[A, B, C]) Query(w *ecs.World, target ...ecs.Entity) Query3[A, B
 		if q.hasTarget {
 			panic("can't change relation target on a query with fixed target")
 		}
-		filter = ecs.RelationFilter(&q.compiled.maskFilter, target[0])
+		q.compiled.relationFilter.Filter = &q.compiled.maskFilter
+		q.compiled.relationFilter.Target = target[0]
+		filter = &q.compiled.relationFilter
 	}
 
 	return Query3[A, B, C]{
@@ -784,7 +792,9 @@ func (q *Filter4[A, B, C, D]) Query(w *ecs.World, target ...ecs.Entity) Query4[A
 		if q.hasTarget {
 			panic("can't change relation target on a query with fixed target")
 		}
-		filter = ecs.RelationFilter(&q.compiled.maskFilter, target[0])
+		q.compiled.relationFilter.Filter = &q.compiled.maskFilter
+		q.compiled.relationFilter.Target = target[0]
+		filter = &q.compiled.relationFilter
 	}
 
 	return Query4[A, B, C, D]{
@@ -968,7 +978,9 @@ func (q *Filter5[A, B, C, D, E]) Query(w *ecs.World, target ...ecs.Entity) Query
 		if q.hasTarget {
 			panic("can't change relation target on a query with fixed target")
 		}
-		filter = ecs.RelationFilter(&q.compiled.maskFilter, target[0])
+		q.compiled.relationFilter.Filter = &q.compiled.maskFilter
+		q.compiled.relationFilter.Target = target[0]
+		filter = &q.compiled.relationFilter
 	}
 
 	return Query5[A, B, C, D, E]{
@@ -1156,7 +1168,9 @@ func (q *Filter6[A, B, C, D, E, F]) Query(w *ecs.World, target ...ecs.Entity) Qu
 		if q.hasTarget {
 			panic("can't change relation target on a query with fixed target")
 		}
-		filter = ecs.RelationFilter(&q.compiled.maskFilter, target[0])
+		q.compiled.relationFilter.Filter = &q.compiled.maskFilter
+		q.compiled.relationFilter.Target = target[0]
+		filter = &q.compiled.relationFilter
 	}
 
 	return Query6[A, B, C, D, E, F]{
@@ -1348,7 +1362,9 @@ func (q *Filter7[A, B, C, D, E, F, G]) Query(w *ecs.World, target ...ecs.Entity)
 		if q.hasTarget {
 			panic("can't change relation target on a query with fixed target")
 		}
-		filter = ecs.RelationFilter(&q.compiled.maskFilter, target[0])
+		q.compiled.relationFilter.Filter = &q.compiled.maskFilter
+		q.compiled.relationFilter.Target = target[0]
+		filter = &q.compiled.relationFilter
 	}
 
 	return Query7[A, B, C, D, E, F, G]{
@@ -1544,7 +1560,9 @@ func (q *Filter8[A, B, C, D, E, F, G, H]) Query(w *ecs.World, target ...ecs.Enti
 		if q.hasTarget {
 			panic("can't change relation target on a query with fixed target")
 		}
-		filter = ecs.RelationFilter(&q.compiled.maskFilter, target[0])
+		q.compiled.relationFilter.Filter = &q.compiled.maskFilter
+		q.compiled.relationFilter.Target = target[0]
+		filter = &q.compiled.relationFilter
 	}
 
 	return Query8[A, B, C, D, E, F, G, H]{


Test patch:
diff --git a/benchmark/arche/fragmentation/arche_test.go b/benchmark/arche/fragmentation/arche_test.go
index 52cd8b4..a6d6730 100644
--- a/benchmark/arche/fragmentation/arche_test.go
+++ b/benchmark/arche/fragmentation/arche_test.go
@@ -272,11 +272,11 @@ func runQuery1Of1kTargets(b *testing.B, count int) {
 	target := targets[0]
 	childBuilder.NewBatch(count, target)
 
-	filter := ecs.RelationFilter(ecs.All(posID, relID), target)
+	filter := ecs.NewRelationFilter(ecs.All(posID, relID), target)
 	b.StartTimer()
 
 	for i := 0; i < b.N; i++ {
-		query := world.Query(filter)
+		query := world.Query(&filter)
 		for query.Next() {
 			pos := (*c.TestStruct0)(query.Get(posID))
 			pos.Val = 1
@@ -305,8 +305,8 @@ func runQuery1Of1kTargetsCached(b *testing.B, count int) {
 	target := targets[0]
 	childBuilder.NewBatch(count, target)
 
-	rf := ecs.RelationFilter(ecs.All(posID, relID), target)
-	cf := world.Cache().Register(rf)
+	rf := ecs.NewRelationFilter(ecs.All(posID, relID), target)
+	cf := world.Cache().Register(&rf)
 	var filter ecs.Filter = &cf
 	b.StartTimer()
 
diff --git a/benchmark/competition/relations/relation_cached_test.go b/benchmark/competition/relations/relation_cached_test.go
index a466dc1..0ac9151 100644
--- a/benchmark/competition/relations/relation_cached_test.go
+++ b/benchmark/competition/relations/relation_cached_test.go
@@ -9,7 +9,8 @@ import (
 )
 
 type ChildFilter struct {
-	Filter ecs.CachedFilter
+	RelFilter ecs.RelationFilter
+	Filter    ecs.CachedFilter
 }
 
 func benchmarkRelationCached(b *testing.B, numParents int, numChildren int) {
@@ -29,8 +30,8 @@ func benchmarkRelationCached(b *testing.B, numParents int, numChildren int) {
 	for spawnedPar.Next() {
 		par := spawnedPar.Entity()
 		_, fl := spawnedPar.Get()
-		rf := ecs.RelationFilter(ecs.All(childID), par)
-		fl.Filter = world.Cache().Register(rf)
+		fl.RelFilter = ecs.NewRelationFilter(ecs.All(childID), par)
+		fl.Filter = world.Cache().Register(&fl.RelFilter)
 
 		parents = append(parents, par)
 	}
diff --git a/benchmark/competition/relations/relation_test.go b/benchmark/competition/relations/relation_test.go
index df03323..451ae15 100644
--- a/benchmark/competition/relations/relation_test.go
+++ b/benchmark/competition/relations/relation_test.go
@@ -43,7 +43,7 @@ func benchmarkRelation(b *testing.B, numParents int, numChildren int) {
 	cf := world.Cache().Register(parentFilter)
 
 	childF := ecs.All(childID)
-
+	relFilter := ecs.NewRelationFilter(childF, ecs.Entity{})
 	b.StartTimer()
 
 	for i := 0; i < b.N; i++ {
@@ -52,9 +52,9 @@ func benchmarkRelation(b *testing.B, numParents int, numChildren int) {
 			parData := (*ParentList)(query.Get(parentID))
 			par := query.Entity()
 
-			cf := ecs.RelationFilter(&childF, par)
+			relFilter.Target = par
 
-			childQuery := world.Query(cf)
+			childQuery := world.Query(&relFilter)
 			for childQuery.Next() {
 				child := (*ChildRelation)(childQuery.Get(childID))
 				parData.Value += child.Value
diff --git a/ecs/archetypes_test.go b/ecs/archetypes_test.go
index b86877b..052d3c8 100644
--- a/ecs/archetypes_test.go
+++ b/ecs/archetypes_test.go
@@ -37,3 +37,11 @@ func TestArchetypePointers(t *testing.T) {
 	assert.Equal(t, unsafe.Pointer(&last), unsafe.Pointer(pt.Get(1)))
 	assert.Equal(t, unsafe.Pointer(&a3), unsafe.Pointer(pt.Get(2)))
 }
+
+func TestBatchArchetype(t *testing.T) {
+	arch := archetype{}
+	batch := batchArchetype{Archetype: &arch}
+
+	assert.Equal(t, &arch, batch.Get(0))
+	assert.Equal(t, int32(1), batch.Len())
+}
diff --git a/ecs/bitmask_test.go b/ecs/bitmask_test.go
index 264f532..d79e8ba 100644
--- a/ecs/bitmask_test.go
+++ b/ecs/bitmask_test.go
@@ -46,24 +46,24 @@ func TestBitMask(t *testing.T) {
 
 func TestBitMaskWithoutExclusive(t *testing.T) {
 	mask := ecs.All(ecs.ID(1), ecs.ID(2), ecs.ID(13))
-	assert.True(t, mask.Matches(ecs.All(ecs.ID(1), ecs.ID(2), ecs.ID(13)), nil))
-	assert.True(t, mask.Matches(ecs.All(ecs.ID(1), ecs.ID(2), ecs.ID(13), ecs.ID(27)), nil))
+	assert.True(t, mask.Matches(ecs.All(ecs.ID(1), ecs.ID(2), ecs.ID(13))))
+	assert.True(t, mask.Matches(ecs.All(ecs.ID(1), ecs.ID(2), ecs.ID(13), ecs.ID(27))))
 
-	assert.False(t, mask.Matches(ecs.All(ecs.ID(1), ecs.ID(2)), nil))
+	assert.False(t, mask.Matches(ecs.All(ecs.ID(1), ecs.ID(2))))
 
 	without := mask.Without(ecs.ID(3))
 
-	assert.True(t, without.Matches(ecs.All(ecs.ID(1), ecs.ID(2), ecs.ID(13)), nil))
-	assert.True(t, without.Matches(ecs.All(ecs.ID(1), ecs.ID(2), ecs.ID(13), ecs.ID(27)), nil))
+	assert.True(t, without.Matches(ecs.All(ecs.ID(1), ecs.ID(2), ecs.ID(13))))
+	assert.True(t, without.Matches(ecs.All(ecs.ID(1), ecs.ID(2), ecs.ID(13), ecs.ID(27))))
 
-	assert.False(t, without.Matches(ecs.All(ecs.ID(1), ecs.ID(2), ecs.ID(3), ecs.ID(13)), nil))
-	assert.False(t, without.Matches(ecs.All(ecs.ID(1), ecs.ID(2)), nil))
+	assert.False(t, without.Matches(ecs.All(ecs.ID(1), ecs.ID(2), ecs.ID(3), ecs.ID(13))))
+	assert.False(t, without.Matches(ecs.All(ecs.ID(1), ecs.ID(2))))
 
 	excl := mask.Exclusive()
 
-	assert.True(t, excl.Matches(ecs.All(ecs.ID(1), ecs.ID(2), ecs.ID(13)), nil))
-	assert.False(t, excl.Matches(ecs.All(ecs.ID(1), ecs.ID(2), ecs.ID(13), ecs.ID(27)), nil))
-	assert.False(t, excl.Matches(ecs.All(ecs.ID(1), ecs.ID(2), ecs.ID(3), ecs.ID(13)), nil))
+	assert.True(t, excl.Matches(ecs.All(ecs.ID(1), ecs.ID(2), ecs.ID(13))))
+	assert.False(t, excl.Matches(ecs.All(ecs.ID(1), ecs.ID(2), ecs.ID(13), ecs.ID(27))))
+	assert.False(t, excl.Matches(ecs.All(ecs.ID(1), ecs.ID(2), ecs.ID(3), ecs.ID(13))))
 }
 
 func TestBitMask128(t *testing.T) {
@@ -191,7 +191,7 @@ func BenchmarkMaskFilter(b *testing.B) {
 	b.StartTimer()
 	var v bool
 	for i := 0; i < b.N; i++ {
-		v = mask.Matches(bits, nil)
+		v = mask.Matches(bits)
 	}
 	b.StopTimer()
 	v = !v
@@ -233,7 +233,7 @@ func BenchmarkMask(b *testing.B) {
 	b.StartTimer()
 	var v bool
 	for i := 0; i < b.N; i++ {
-		v = mask.Matches(bits, nil)
+		v = mask.Matches(bits)
 	}
 	b.StopTimer()
 	v = !v
diff --git a/ecs/cache_test.go b/ecs/cache_test.go
index 662a64c..a43e96b 100644
--- a/ecs/cache_test.go
+++ b/ecs/cache_test.go
@@ -47,6 +47,52 @@ func TestFilterCache(t *testing.T) {
 	assert.Panics(t, func() { cache.get(&f1) })
 }
 
+func TestFilterCacheRelation(t *testing.T) {
+	world := NewWorld()
+	posID := ComponentID[Position](&world)
+	rel1ID := ComponentID[testRelationA](&world)
+	rel2ID := ComponentID[testRelationB](&world)
+
+	target1 := world.NewEntity()
+	target2 := world.NewEntity()
+	target3 := world.NewEntity()
+
+	cache := world.Cache()
+
+	f1 := All(rel1ID)
+	ff1 := cache.Register(f1)
+
+	f2 := NewRelationFilter(f1, target1)
+	ff2 := cache.Register(&f2)
+
+	f3 := NewRelationFilter(f1, target2)
+	ff3 := cache.Register(&f3)
+
+	c1 := world.Cache().get(&ff1)
+	c2 := world.Cache().get(&ff2)
+	c3 := world.Cache().get(&ff3)
+
+	NewBuilder(&world, posID).NewBatch(10)
+
+	assert.Equal(t, int32(0), c1.Archetypes.Len())
+	assert.Equal(t, int32(0), c2.Archetypes.Len())
+	assert.Equal(t, int32(0), c3.Archetypes.Len())
+
+	NewBuilder(&world, rel1ID).WithRelation(rel1ID).NewBatch(10, target1)
+	assert.Equal(t, int32(1), c1.Archetypes.Len())
+	assert.Equal(t, int32(1), c2.Archetypes.Len())
+
+	NewBuilder(&world, rel1ID).WithRelation(rel1ID).NewBatch(10, target3)
+	assert.Equal(t, int32(2), c1.Archetypes.Len())
+	assert.Equal(t, int32(1), c2.Archetypes.Len())
+
+	NewBuilder(&world, rel2ID).WithRelation(rel2ID).NewBatch(10, target2)
+
+	world.Batch().RemoveEntities(All())
+	assert.Equal(t, int32(0), c1.Archetypes.Len())
+	assert.Equal(t, int32(0), c2.Archetypes.Len())
+}
+
 func ExampleCache() {
 	world := NewWorld()
 	posID := ComponentID[Position](&world)
diff --git a/ecs/filter_test.go b/ecs/filter_test.go
index 5a77db1..a5050d0 100644
--- a/ecs/filter_test.go
+++ b/ecs/filter_test.go
@@ -10,11 +10,11 @@ import (
 func TestCachedMaskFilter(t *testing.T) {
 	f := ecs.All(1, 2, 3).Without(4)
 
-	assert.True(t, f.Matches(ecs.All(1, 2, 3), nil))
-	assert.True(t, f.Matches(ecs.All(1, 2, 3, 5), nil))
+	assert.True(t, f.Matches(ecs.All(1, 2, 3)))
+	assert.True(t, f.Matches(ecs.All(1, 2, 3, 5)))
 
-	assert.False(t, f.Matches(ecs.All(1, 2), nil))
-	assert.False(t, f.Matches(ecs.All(1, 2, 3, 4), nil))
+	assert.False(t, f.Matches(ecs.All(1, 2)))
+	assert.False(t, f.Matches(ecs.All(1, 2, 3, 4)))
 }
 
 func TestCachedFilter(t *testing.T) {
@@ -23,8 +23,8 @@ func TestCachedFilter(t *testing.T) {
 	f := ecs.All(1, 2, 3)
 	fc := w.Cache().Register(f)
 
-	assert.Equal(t, f.Matches(ecs.All(1, 2, 3), nil), fc.Matches(ecs.All(1, 2, 3), nil))
-	assert.Equal(t, f.Matches(ecs.All(1, 2), nil), fc.Matches(ecs.All(1, 2), nil))
+	assert.Equal(t, f.Matches(ecs.All(1, 2, 3)), fc.Matches(ecs.All(1, 2, 3)))
+	assert.Equal(t, f.Matches(ecs.All(1, 2)), fc.Matches(ecs.All(1, 2)))
 
 	w.Cache().Unregister(&fc)
 }
@@ -67,9 +67,9 @@ func ExampleRelationFilter() {
 	builder := ecs.NewBuilder(&world, childID).WithRelation(childID)
 	builder.NewBatch(100, target)
 
-	filter := ecs.RelationFilter(ecs.All(childID), target)
+	filter := ecs.NewRelationFilter(ecs.All(childID), target)
 
-	query := world.Query(filter)
+	query := world.Query(&filter)
 	for query.Next() {
 		// ...
 	}
diff --git a/ecs/query_test.go b/ecs/query_test.go
index 86eccd7..c3d723f 100644
--- a/ecs/query_test.go
+++ b/ecs/query_test.go
@@ -10,10 +10,10 @@ func TestMask(t *testing.T) {
 	filter := All(0, 2, 4)
 	other := All(0, 1, 2)
 
-	assert.False(t, filter.Matches(other, nil))
+	assert.False(t, filter.Matches(other))
 
 	other = All(0, 1, 2, 3, 4)
-	assert.True(t, filter.Matches(other, nil))
+	assert.True(t, filter.Matches(other))
 }
 
 func TestQuery(t *testing.T) {
@@ -161,8 +161,8 @@ func TestQueryCachedRelation(t *testing.T) {
 	target1 := w.NewEntity()
 	target2 := w.NewEntity()
 
-	relFilter := RelationFilter(All(relID), target1)
-	cf := w.Cache().Register(relFilter)
+	relFilter := NewRelationFilter(All(relID), target1)
+	cf := w.Cache().Register(&relFilter)
 
 	q := w.Query(&cf)
 	assert.Equal(t, 0, q.Count())
@@ -182,8 +182,8 @@ func TestQueryCachedRelation(t *testing.T) {
 	}
 	assert.Equal(t, 10, cnt)
 
-	relFilter = RelationFilter(All(relID), target2)
-	cf = w.Cache().Register(relFilter)
+	relFilter = NewRelationFilter(All(relID), target2)
+	cf = w.Cache().Register(&relFilter)
 
 	q = w.Query(&cf)
 	assert.Equal(t, 0, q.Count())
@@ -260,7 +260,7 @@ func TestQueryCount(t *testing.T) {
 
 type testFilter struct{}
 
-func (f testFilter) Matches(bits Mask, relation *Entity) bool {
+func (f testFilter) Matches(bits Mask) bool {
 	return true
 }
 
diff --git a/ecs/relation_examples_test.go b/ecs/relation_examples_test.go
index 1074cf7..a7ea81a 100644
--- a/ecs/relation_examples_test.go
+++ b/ecs/relation_examples_test.go
@@ -25,9 +25,9 @@ func ExampleRelation() {
 	_ = world.Relations().Get(child, childID)
 
 	// Filter for the relation.
-	filter := ecs.RelationFilter(ecs.All(childID), parent)
+	filter := ecs.NewRelationFilter(ecs.All(childID), parent)
 
-	query := world.Query(filter)
+	query := world.Query(&filter)
 	for query.Next() {
 		fmt.Println(
 			query.Entity(),
diff --git a/ecs/relation_stress_test.go b/ecs/relation_stress_test.go
index 105f3da..441a989 100644
--- a/ecs/relation_stress_test.go
+++ b/ecs/relation_stress_test.go
@@ -40,8 +40,8 @@ func TestRelationStress(t *testing.T) {
 			parIdx := rand.Intn(numParents)
 			parent := parents[parIdx]
 
-			childFilter := ecs.RelationFilter(ecs.All(relID), parent)
-			removed := world.Batch().RemoveEntities(childFilter)
+			childFilter := ecs.NewRelationFilter(ecs.All(relID), parent)
+			removed := world.Batch().RemoveEntities(&childFilter)
 			world.RemoveEntity(parent)
 			assert.Equal(t, numChildren, removed)
 
diff --git a/ecs/world_benchmark_test.go b/ecs/world_benchmark_test.go
index b553d0e..1ad59a2 100644
--- a/ecs/world_benchmark_test.go
+++ b/ecs/world_benchmark_test.go
@@ -150,6 +150,80 @@ func BenchmarkRemoveEntities_10_000(b *testing.B) {
 	}
 }
 
+func BenchmarkWorldNewQuery(b *testing.B) {
+	b.StopTimer()
+	world := NewWorld(NewConfig().WithCapacityIncrement(10000))
+	posID := ComponentID[Position](&world)
+	velID := ComponentID[Velocity](&world)
+
+	NewBuilder(&world, posID, velID).NewBatch(25)
+
+	filter := All(posID, velID)
+	b.StartTimer()
+
+	for i := 0; i < b.N; i++ {
+		q := world.Query(filter)
+		q.Close()
+	}
+}
+
+func BenchmarkWorldNewQueryNext(b *testing.B) {
+	b.StopTimer()
+	world := NewWorld(NewConfig().WithCapacityIncrement(10000))
+
+	posID := ComponentID[Position](&world)
+	velID := ComponentID[Velocity](&world)
+
+	NewBuilder(&world, posID, velID).NewBatch(25)
+
+	filter := All(posID, velID)
+	b.StartTimer()
+
+	for i := 0; i < b.N; i++ {
+		q := world.Query(filter)
+		q.Next()
+		q.Close()
+	}
+}
+
+func BenchmarkWorldNewQueryCached(b *testing.B) {
+	b.StopTimer()
+	world := NewWorld(NewConfig().WithCapacityIncrement(10000))
+	posID := ComponentID[Position](&world)
+	velID := ComponentID[Velocity](&world)
+
+	NewBuilder(&world, posID, velID).NewBatch(25)
+
+	filter := All(posID, velID)
+	cf := world.Cache().Register(filter)
+	b.StartTimer()
+
+	for i := 0; i < b.N; i++ {
+		q := world.Query(&cf)
+		q.Close()
+	}
+}
+
+func BenchmarkWorldNewQueryNextCached(b *testing.B) {
+	b.StopTimer()
+	world := NewWorld(NewConfig().WithCapacityIncrement(10000))
+
+	posID := ComponentID[Position](&world)
+	velID := ComponentID[Velocity](&world)
+
+	NewBuilder(&world, posID, velID).NewBatch(25)
+
+	filter := All(posID, velID)
+	cf := world.Cache().Register(filter)
+	b.StartTimer()
+
+	for i := 0; i < b.N; i++ {
+		q := world.Query(&cf)
+		q.Next()
+		q.Close()
+	}
+}
+
 func BenchmarkRemoveEntitiesBatch_10_000(b *testing.B) {
 	for i := 0; i < b.N; i++ {
 		b.StopTimer()
diff --git a/ecs/world_test.go b/ecs/world_test.go
index b5d6f80..f49e493 100644
--- a/ecs/world_test.go
+++ b/ecs/world_test.go
@@ -302,8 +302,8 @@ func TestWorldExchangeBatch(t *testing.T) {
 	query.Close()
 
 	cnt = 0
-	filter2 := RelationFilter(All(relID), target1)
-	w.Batch().Exchange(filter2, []ID{posID}, []ID{velID}, func(q Query) {
+	filter2 := NewRelationFilter(All(relID), target1)
+	w.Batch().Exchange(&filter2, []ID{posID}, []ID{velID}, func(q Query) {
 		assert.Equal(t, 100, q.Count())
 		for q.Next() {
 			assert.True(t, q.Has(posID))
@@ -321,7 +321,8 @@ func TestWorldExchangeBatch(t *testing.T) {
 
 	w.Batch().Exchange(All(posID), nil, nil, nil)
 
-	w.Batch().Exchange(RelationFilter(All(relID), target2), nil, []ID{relID}, nil)
+	relFilter := NewRelationFilter(All(relID), target2)
+	w.Batch().Exchange(&relFilter, nil, []ID{relID}, nil)
 	w.Batch().Exchange(All(relID), nil, []ID{relID}, nil)
 
 	w.Batch().RemoveEntities(All(posID))
@@ -771,7 +772,8 @@ func TestWorldRelationSetBatch(t *testing.T) {
 	builder.NewBatch(100, targ2)
 	builder.NewBatch(100, targ3)
 
-	world.Batch().SetRelation(RelationFilter(All(relID), targ2), relID, targ1, func(q Query) {
+	relFilter := NewRelationFilter(All(relID), targ2)
+	world.Batch().SetRelation(&relFilter, relID, targ1, func(q Query) {
 		assert.Equal(t, 100, q.Count())
 		for q.Next() {
 			assert.Equal(t, targ1, q.Relation(relID))
@@ -787,14 +789,16 @@ func TestWorldRelationSetBatch(t *testing.T) {
 	})
 	assert.Equal(t, 300, total)
 
-	world.Batch().SetRelation(RelationFilter(All(relID), targ3), relID, Entity{}, func(q Query) {
+	relFilter = NewRelationFilter(All(relID), targ3)
+	world.Batch().SetRelation(&relFilter, relID, Entity{}, func(q Query) {
 		assert.Equal(t, 300, q.Count())
 		for q.Next() {
 			assert.True(t, q.Relation(relID).IsZero())
 		}
 	})
 
-	world.Batch().SetRelation(RelationFilter(All(relID), Entity{}), relID, targ1, nil)
+	relFilter = NewRelationFilter(All(relID), Entity{})
+	world.Batch().SetRelation(&relFilter, relID, targ1, nil)
 
 	world.RemoveEntity(targ3)
 	assert.Panics(t, func() {
@@ -828,8 +832,8 @@ func TestWorldRelationRemove(t *testing.T) {
 	e1 := world.NewEntity(relID, rotID)
 	e2 := world.NewEntity(relID, rotID)
 
-	filter := RelationFilter(All(relID), targ)
-	world.Cache().Register(filter)
+	filter := NewRelationFilter(All(relID), targ)
+	world.Cache().Register(&filter)
 
 	assert.Equal(t, int32(3), world.nodes.Len())
 	assert.Equal(t, int32(1), world.nodes.Get(2).archetypes.Len())
@@ -914,18 +918,18 @@ func TestWorldRelationQuery(t *testing.T) {
 	}
 	assert.Equal(t, 8, cnt)
 
-	filter2 := RelationFilter(All(relID), targ1)
-	query = world.Query(filter2)
+	filter2 := NewRelationFilter(All(relID), targ1)
+	query = world.Query(&filter2)
 	assert.Equal(t, 4, query.Count())
 	query.Close()
 
-	filter2 = RelationFilter(All(relID), targ2)
-	query = world.Query(filter2)
+	filter2 = NewRelationFilter(All(relID), targ2)
+	query = world.Query(&filter2)
 	assert.Equal(t, 4, query.Count())
 	query.Close()
 
-	filter2 = RelationFilter(All(relID), targ3)
-	query = world.Query(filter2)
+	filter2 = NewRelationFilter(All(relID), targ3)
+	query = world.Query(&filter2)
 	assert.Equal(t, 0, query.Count())
 	cnt = 0
 	for query.Next() {
@@ -971,22 +975,22 @@ func TestWorldRelationQueryCached(t *testing.T) {
 	assert.Equal(t, 8, cnt)
 	world.Cache().Unregister(&regFilter)
 
-	filter2 := RelationFilter(All(relID), targ1)
-	regFilter2 := world.Cache().Register(filter2)
+	filter2 := NewRelationFilter(All(relID), targ1)
+	regFilter2 := world.Cache().Register(&filter2)
 	query = world.Query(&regFilter2)
 	assert.Equal(t, 4, query.Count())
 	query.Close()
 	world.Cache().Unregister(&regFilter2)
 
-	filter2 = RelationFilter(All(relID), targ2)
-	regFilter2 = world.Cache().Register(filter2)
+	filter2 = NewRelationFilter(All(relID), targ2)
+	regFilter2 = world.Cache().Register(&filter2)
 	query = world.Query(&regFilter2)
 	assert.Equal(t, 4, query.Count())
 	query.Close()
 	world.Cache().Unregister(&regFilter2)
 
-	filter2 = RelationFilter(All(relID), targ3)
-	regFilter2 = world.Cache().Register(filter2)
+	filter2 = NewRelationFilter(All(relID), targ3)
+	regFilter2 = world.Cache().Register(&filter2)
 	query = world.Query(&regFilter2)
 	assert.Equal(t, 0, query.Count())
 	query.Close()
@@ -1015,8 +1019,8 @@ func TestWorldRelation(t *testing.T) {
 	assert.Equal(t, 25, parQuery.Count())
 	for parQuery.Next() {
 		targ := (*Position)(parQuery.Get(posID))
-		filter := RelationFilter(All(relID), parQuery.Entity())
-		query := world.Query(filter)
+		filter := NewRelationFilter(All(relID), parQuery.Entity())
+		query := world.Query(&filter)
 		assert.Equal(t, 100, query.Count())
 		for query.Next() {
 			targ.Y++
@@ -1286,20 +1290,17 @@ func TestWorldBatchRemove(t *testing.T) {
 	world.Batch().RemoveEntities(&filter)
 	world.Cache().Unregister(&filter2)
 
-	world.Batch().RemoveEntities(
-		RelationFilter(All(rotID, relID), target1),
-	)
+	relFilter := NewRelationFilter(All(rotID, relID), target1)
+	world.Batch().RemoveEntities(&relFilter)
 
-	world.Batch().RemoveEntities(
-		RelationFilter(All(rotID, relID), target2),
-	)
+	relFilter = NewRelationFilter(All(rotID, relID), target2)
+	world.Batch().RemoveEntities(&relFilter)
 
 	filter = All().Exclusive()
 	world.Batch().RemoveEntities(&filter)
 
-	world.Batch().RemoveEntities(
-		RelationFilter(All(rotID, relID), target3),
-	)
+	relFilter = NewRelationFilter(All(rotID, relID), target3)
+	world.Batch().RemoveEntities(&relFilter)
 
 	query := world.Query(All())
 	assert.Equal(t, 0, query.Count())
diff --git a/filter/filter_test.go b/filter/filter_test.go
index 759d2dd..f1ea3bb 100644
--- a/filter/filter_test.go
+++ b/filter/filter_test.go
@@ -183,7 +183,7 @@ func TestFilter(t *testing.T) {
 }
 
 func match(f ecs.Filter, m ecs.Mask) bool {
-	return f.Matches(m, nil)
+	return f.Matches(m)
 }
 
 func TestInterface(t *testing.T) {
@@ -204,7 +204,7 @@ func BenchmarkFilterStackOr(b *testing.B) {
 	b.StartTimer()
 
 	for i := 0; i < b.N; i++ {
-		_ = filter.Matches(ecs.Mask(mask), nil)
+		_ = filter.Matches(ecs.Mask(mask))
 	}
 }
 
@@ -216,7 +216,7 @@ func BenchmarkFilterHeapOr(b *testing.B) {
 	b.StartTimer()
 
 	for i := 0; i < b.N; i++ {
-		_ = filter.Matches(ecs.Mask(mask), nil)
+		_ = filter.Matches(ecs.Mask(mask))
 	}
 }
 
@@ -231,7 +231,7 @@ func BenchmarkFilterStack5And(b *testing.B) {
 	b.StartTimer()
 
 	for i := 0; i < b.N; i++ {
-		_ = filter.Matches(ecs.Mask(mask), nil)
+		_ = filter.Matches(ecs.Mask(mask))
 	}
 }
 
@@ -243,6 +243,6 @@ func BenchmarkFilterHeap5And(b *testing.B) {
 	b.StartTimer()
 
 	for i := 0; i < b.N; i++ {
-		_ = filter.Matches(ecs.Mask(mask), nil)
+		_ = filter.Matches(ecs.Mask(mask))
 	}
 }