Skip to content

Commit 990ba68

Browse files
committed
feat(query): enhance query caching and reference management
- Implemented caching for queries based on component types and filters. - Added reference counting to manage query instances efficiently. - Updated the Query class to support a unique key for cached queries. - Enhanced the World class to handle query creation and release with proper reference counting. - Improved serialization of QueryFilter for cache key generation.
1 parent 6c35984 commit 990ba68

4 files changed

Lines changed: 193 additions & 14 deletions

File tree

src/query-filter.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Archetype } from "./archetype";
22
import type { EntityId } from "./entity";
3-
import { getDetailedIdType, decodeRelationId, isRelationId } from "./entity";
3+
import { decodeRelationId, getDetailedIdType, isRelationId } from "./entity";
44

55
/**
66
* Filter options for queries
@@ -9,6 +9,16 @@ export interface QueryFilter {
99
negativeComponentTypes?: EntityId<any>[];
1010
}
1111

12+
/**
13+
* Serialize a QueryFilter into a deterministic string suitable for cache keys.
14+
* Currently only serializes `negativeComponentTypes`.
15+
*/
16+
export function serializeQueryFilter(filter: QueryFilter = {}): string {
17+
const negative = (filter.negativeComponentTypes || []).slice().sort((a, b) => a - b);
18+
if (negative.length === 0) return "";
19+
return `neg:${negative.join(",")}`;
20+
}
21+
1222
/**
1323
* Check if an archetype matches the given component types
1424
*/

src/query.test.ts

Lines changed: 106 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -304,11 +304,113 @@ describe("Query", () => {
304304
// entity3 doesn't have position
305305

306306
world.flushCommands();
307+
});
308+
});
307309

308-
const entities = query.getEntities();
309-
expect(entities).toContain(entity1);
310-
expect(entities).not.toContain(entity2);
311-
expect(entities).not.toContain(entity3);
310+
describe("Query Caching and Reference Counting", () => {
311+
type Position = { x: number; y: number };
312+
type Velocity = { x: number; y: number };
313+
314+
const positionComponent = component<Position>();
315+
const velocityComponent = component<Velocity>();
316+
317+
it("should cache queries and return the same instance for identical queries", () => {
318+
const world = new World();
319+
320+
// Create two queries with the same component types
321+
const query1 = world.createQuery([positionComponent]);
322+
const query2 = world.createQuery([positionComponent]);
323+
324+
// Should return the same cached instance
325+
expect(query1).toBe(query2);
326+
});
327+
328+
it("should cache queries with different component orders as the same query", () => {
329+
const world = new World();
330+
331+
// Create queries with same components but different order
332+
const query1 = world.createQuery([positionComponent, velocityComponent]);
333+
const query2 = world.createQuery([velocityComponent, positionComponent]);
334+
335+
// Should return the same cached instance (sorted internally)
336+
expect(query1).toBe(query2);
337+
});
338+
339+
it("should create different queries for different component combinations", () => {
340+
const world = new World();
341+
342+
const query1 = world.createQuery([positionComponent]);
343+
const query2 = world.createQuery([velocityComponent]);
344+
const query3 = world.createQuery([positionComponent, velocityComponent]);
345+
346+
// All should be different instances
347+
expect(query1).not.toBe(query2);
348+
expect(query1).not.toBe(query3);
349+
expect(query2).not.toBe(query3);
350+
});
351+
352+
it("should properly handle reference counting", () => {
353+
const world = new World();
354+
355+
// Create multiple references to the same query
356+
const query1 = world.createQuery([positionComponent]);
357+
const query2 = world.createQuery([positionComponent]);
358+
const query3 = world.createQuery([positionComponent]);
359+
360+
// All should be the same instance
361+
expect(query1).toBe(query2);
362+
expect(query2).toBe(query3);
363+
364+
// Release all three references
365+
world.releaseQuery(query1);
366+
world.releaseQuery(query2);
367+
world.releaseQuery(query3);
368+
369+
// Now create a new query - should be a new instance since cache was cleared
370+
const query4 = world.createQuery([positionComponent]);
371+
expect(query4).not.toBe(query1); // Should be a new instance
372+
});
373+
374+
it("should handle releaseQuery on non-cached queries gracefully", () => {
375+
const world = new World();
376+
377+
// Create a query and immediately release it
378+
const query = world.createQuery([positionComponent]);
379+
world.releaseQuery(query);
380+
381+
// Should not throw and should create a new instance next time
382+
const query2 = world.createQuery([positionComponent]);
383+
expect(query2).not.toBe(query);
384+
});
385+
386+
it("should cache queries with filters separately", () => {
387+
const world = new World();
388+
type Health = { value: number };
389+
const healthComponent = component<Health>();
390+
391+
// Create queries with and without filters
392+
const query1 = world.createQuery([positionComponent]);
393+
const query2 = world.createQuery([positionComponent], { negativeComponentTypes: [healthComponent] });
394+
395+
// Should be different instances due to different filters
396+
expect(query1).not.toBe(query2);
397+
});
398+
399+
it("should maintain separate caches for queries with different filters", () => {
400+
const world = new World();
401+
type Health = { value: number };
402+
const healthComponent = component<Health>();
403+
404+
// Create multiple queries with the same filter
405+
const query1 = world.createQuery([positionComponent], { negativeComponentTypes: [healthComponent] });
406+
const query2 = world.createQuery([positionComponent], { negativeComponentTypes: [healthComponent] });
407+
408+
// Should return the same cached instance
409+
expect(query1).toBe(query2);
410+
411+
// Create queries with different filters
412+
const query3 = world.createQuery([positionComponent], { negativeComponentTypes: [velocityComponent] });
413+
expect(query1).not.toBe(query3);
312414
});
313415
});
314416
});

src/query.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,30 @@
11
import { Archetype } from "./archetype";
22
import type { EntityId } from "./entity";
3-
import type { ComponentTuple } from "./types";
43
import { matchesComponentTypes, matchesFilter, type QueryFilter } from "./query-filter";
4+
import type { ComponentTuple } from "./types";
55
import type { World } from "./world";
66

77
/**
88
* Query class for efficient entity queries with cached archetypes
99
*/
1010
export class Query {
11+
// Public key used by World to identify cached queries
12+
public readonly key: string;
13+
1114
private world: World<any[]>;
1215
private componentTypes: EntityId<any>[];
1316
private filter: QueryFilter;
1417
private cachedArchetypes: Archetype[] = [];
1518
private isDisposed = false;
1619

17-
constructor(world: World<any[]>, componentTypes: EntityId<any>[], filter: QueryFilter = {}) {
20+
constructor(world: World<any[]>, componentTypes: EntityId<any>[], filter: QueryFilter = {}, key?: string) {
1821
this.world = world;
1922
this.componentTypes = [...componentTypes].sort((a, b) => a - b);
2023
this.filter = filter;
24+
this.key = key ?? `${this.componentTypes.join(",")}|`;
2125
this.updateCache();
2226
// Register with world for archetype updates
23-
world.registerQuery(this);
27+
world._registerQuery(this);
2428
}
2529

2630
/**
@@ -129,9 +133,23 @@ export class Query {
129133
/**
130134
* Dispose the query and disconnect from world
131135
*/
136+
/**
137+
* Request disposal of this query.
138+
* This will decrement the world's reference count for the query.
139+
* The query will only be fully disposed when the ref count reaches zero.
140+
*/
132141
dispose(): void {
142+
// Ask the world to release this query (decrement refcount and fully dispose when zero)
143+
this.world.releaseQuery(this);
144+
}
145+
146+
/**
147+
* Internal full dispose called by World when refCount reaches zero.
148+
*/
149+
_disposeInternal(): void {
133150
if (!this.isDisposed) {
134-
this.world.unregisterQuery(this);
151+
// Unregister from world (remove from notification list)
152+
this.world._unregisterQuery(this);
135153
this.cachedArchetypes = [];
136154
this.isDisposed = true;
137155
}

src/world.ts

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { Archetype } from "./archetype";
22
import { CommandBuffer, type Command } from "./command-buffer";
33
import type { EntityId, WildcardRelationId } from "./entity";
4-
import { EntityIdManager, relation, getDetailedIdType, getIdType, isWildcardRelationId } from "./entity";
4+
import { EntityIdManager, getDetailedIdType, getIdType, relation } from "./entity";
55
import { Query } from "./query";
6-
import type { QueryFilter } from "./query-filter";
6+
import { serializeQueryFilter, type QueryFilter } from "./query-filter";
77
import type { System } from "./system";
88
import { SystemScheduler } from "./system-scheduler";
99
import type { ComponentTuple, LifecycleHook } from "./types";
@@ -20,6 +20,8 @@ export class World<ExtraParams extends any[] = [deltaTime: number]> {
2020
private entityToArchetype = new Map<EntityId, Archetype>();
2121
private systemScheduler = new SystemScheduler<ExtraParams>();
2222
private queries: Query[] = [];
23+
// Cache for queries keyed by component types + filter signature
24+
private queryCache = new Map<string, { query: Query; refCount: number }>();
2325
private commandBuffer: CommandBuffer;
2426
private componentToArchetypes = new Map<EntityId<any>, Archetype[]>();
2527

@@ -257,26 +259,73 @@ export class World<ExtraParams extends any[] = [deltaTime: number]> {
257259
* Create a cached query for efficient entity lookups
258260
*/
259261
createQuery(componentTypes: EntityId<any>[], filter: QueryFilter = {}): Query {
260-
return new Query(this, componentTypes, filter);
262+
// Build a deterministic key for the query (component types sorted + filter negative components sorted)
263+
const sortedTypes = [...componentTypes].sort((a, b) => a - b);
264+
const filterKey = serializeQueryFilter(filter);
265+
const key = `${this.getComponentTypesHash(sortedTypes)}${filterKey ? `|${filterKey}` : ""}`;
266+
267+
const cached = this.queryCache.get(key);
268+
if (cached) {
269+
cached.refCount++;
270+
return cached.query;
271+
}
272+
273+
const query = new Query(this, sortedTypes, filter, key);
274+
this.queryCache.set(key, { query, refCount: 1 });
275+
return query;
261276
}
262277

263278
/**
264279
* @internal Register a query for archetype update notifications
265280
*/
266-
registerQuery(query: Query): void {
281+
_registerQuery(query: Query): void {
267282
this.queries.push(query);
268283
}
269284

270285
/**
271286
* @internal Unregister a query
272287
*/
273-
unregisterQuery(query: Query): void {
288+
_unregisterQuery(query: Query): void {
274289
const index = this.queries.indexOf(query);
275290
if (index !== -1) {
276291
this.queries.splice(index, 1);
277292
}
278293
}
279294

295+
/**
296+
* Release a query reference obtained from createQuery.
297+
* Decrements the refCount and fully disposes the query when it reaches zero.
298+
*/
299+
releaseQuery(query: Query): void {
300+
const key = query.key;
301+
// Fallback: try to find by identity
302+
for (const [k, v] of this.queryCache.entries()) {
303+
if (v.query === query) {
304+
v.refCount--;
305+
if (v.refCount <= 0) {
306+
this.queryCache.delete(k);
307+
// Fully dispose the query (will unregister it from notification list)
308+
v.query._disposeInternal();
309+
}
310+
return;
311+
}
312+
}
313+
314+
const entry = this.queryCache.get(key);
315+
if (!entry) {
316+
// Nothing cached, ensure it's unregistered
317+
this._unregisterQuery(query);
318+
return;
319+
}
320+
321+
entry.refCount--;
322+
if (entry.refCount <= 0) {
323+
this.queryCache.delete(key);
324+
// Fully dispose the query (will unregister it from notification list)
325+
entry.query._disposeInternal();
326+
}
327+
}
328+
280329
/**
281330
* @internal Get archetypes that match specific component types (for internal use by queries)
282331
*/

0 commit comments

Comments
 (0)