Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,8 @@ const q = world.query(Position, Velocity);
q.count; // number of matching entities
q.isEmpty;
q.first; // Entity | undefined; world.queryFirst(...) is sugar for this
// On a Changed/Added/Removed query these answer for the filter, and reading them
// does not count as a run: each() afterwards still sees the same entities.
for (const e of q) {
} // Tier 1: handles, read values through the world
q.each((p, v, e) => {});
Expand Down Expand Up @@ -512,7 +514,8 @@ world.on('exit', world.query(Position, IsActive), (entity) => {});
**Pull: change ticks.** The world holds a counter that `world.step()` advances. `Changed`, `Added`
and `Removed` compare against it, which is a scan of a `Uint32Array` with no calls in it. Each such
query remembers its own last-seen tick, so two systems watching the same trait do not consume each
other's events.
other's events. Only `each()` advances it: `count`, `isEmpty`, `first`, `entities()` and `for … of`
answer for the filter without consuming the window, so `if (!q.isEmpty) q.each(…)` sees everything.

```ts
world.step();
Expand Down
2 changes: 1 addition & 1 deletion specs/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,7 @@ world.query(Added(Velocity));
world.query(Removed(Velocity)); // valid for one tick after removal
```

Each `Changed`/`Added`/`Removed` query stores its own last-seen tick, so two systems observing the same trait do not steal each other's events.
Each `Changed`/`Added`/`Removed` query stores its own last-seen tick, so two systems observing the same trait do not steal each other's events. Only a run (`each`) advances it. The reads — `count`, `isEmpty`, `first`, `entities()` and `for … of` — answer for the filter as it stands, without consuming the window, so a read before a run never costs the run its events.

Ticks are written by `world.set` and by cursor setters. **Direct chunk writes bypass them** — call `chunk.markChanged(trait)` or `world.markChanged(e, trait)`. Both forms update the per-row ticks _and_ the column's `lastWriteTick`.

Expand Down
75 changes: 75 additions & 0 deletions src/core/materialized.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,19 @@ export interface View {
export class ListWalk {
/** `null` when the list is known to hold only matching entities. */
readonly #plan: QueryPlan | null;
readonly #terms: readonly Term[];
readonly #binding: Binding;
readonly #filter: RowFilter | null;
/** The non-consuming twin of `#filter` for reads (see `QueryResult`). */
#probe: RowFilter | null = null;
readonly #entities: EntityIndex;
readonly #archetypes: readonly Archetype[];
readonly #ticks: Ticks;
readonly #iteration: Iteration;

public constructor(terms: readonly Term[], plan: QueryPlan | null, cache: QueryCache) {
this.#plan = plan;
this.#terms = terms;
this.#binding = new Binding(terms);
this.#filter = RowFilter.of(terms);
this.#entities = cache.entities;
Expand All @@ -53,6 +57,11 @@ export class ListWalk {
this.#binding.retrack(trait);
}

/** Whether the terms carry a tick filter, i.e. whether the reads must go through the walk. */
public get filtered(): boolean {
return this.#filter !== null;
}

public each(
fn: (...args: any[]) => unknown,
entities: Float64Array,
Expand All @@ -76,10 +85,19 @@ export class ListWalk {
}

public iterator(entities: Float64Array, n: number, step: number): Iterator<Entity> {
const filter = this.#filter;
if (filter !== null) {
const hits = this.#scanFiltered(filter, entities, n, step, Infinity);
return (hits as unknown as Iterable<Entity>)[Symbol.iterator]();
}
return new ListIterator(entities, n, step, this.#entities, this.#plan, this.#archetypes);
}

public count(entities: Float64Array, n: number): number {
const filter = this.#filter;
if (filter !== null) {
return this.#scanFiltered(filter, entities, n, 1, Infinity).length;
}
const { generations, archetypes: archetypeIds } = this.#entities;
const plan = this.#plan;
let total = 0;
Expand All @@ -97,11 +115,20 @@ export class ListWalk {
}

public first(entities: Float64Array, n: number, step: number): Entity | undefined {
const filter = this.#filter;
if (filter !== null) {
const hits = this.#scanFiltered(filter, entities, n, step, 1);
return hits.length === 0 ? undefined : (hits[0] as Entity);
}
const result = this.iterator(entities, n, step).next();
return result.done ? undefined : result.value;
}

public collect(entities: Float64Array, n: number, step: number): Float64Array {
const filter = this.#filter;
if (filter !== null) {
return this.#scanFiltered(filter, entities, n, step, Infinity);
}
const out = new Float64Array(this.count(entities, n));
const iterator = this.iterator(entities, n, step);
let at = 0;
Expand All @@ -111,6 +138,54 @@ export class ListWalk {
return out;
}

/**
* The live entities of the list the tick filter accepts right now, in list
* order, stopping after `limit` hits. Reads through the probe, so `each`
* still sees every one of them afterwards (SPEC §8.3).
*/
#scanFiltered(
filter: RowFilter,
entities: Float64Array,
n: number,
step: number,
limit: number,
): Float64Array {
const probe = (this.#probe ??= RowFilter.of(this.#terms)!);
const hits: number[] = [];
if (!probe.peek(this.#ticks, filter.lastSeen)) {
return new Float64Array(0);
}
const plan = this.#plan;
const { generations, archetypes: archetypeIds, rows } = this.#entities;
const archetypes = this.#archetypes;
let bound: Archetype | null = null;
let bindable = false;

for (let k = step > 0 ? 0 : n - 1; k >= 0 && k < n && hits.length < limit; k += step) {
const entity = entities[k] as Entity;
const id = entityId(entity);
if (generations[id] !== entityGeneration(entity)) {
continue;
}
const archetype = archetypes[archetypeIds[id]];
if (archetype !== bound) {
bound = archetype;
bindable = plan === null || plan.test(archetype.mask);
if (bindable) {
probe.bind(archetype);
}
}
if (!bindable) {
continue;
}
const row = rows[id];
if (probe.accept(entity, row >>> archetype.pageShift, row & archetype.pageMask)) {
hits.push(entity);
}
}
return Float64Array.from(hits);
}

#walk(fn: (...args: any[]) => unknown, entities: Float64Array, n: number, step: number): void {
const filter = this.#filter;
const ticks = this.#ticks;
Expand Down
61 changes: 61 additions & 0 deletions src/core/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,12 @@ export class QueryResult<T extends readonly Term[] = readonly Term[]> {
readonly #key: string;
readonly #binding: Binding;
readonly #filter: RowFilter | null;
/**
* A second filter over the same terms for the reads that must not consume
* the change window (`count`, `first`, `entities()`, for-of): it is opened at
* the live filter's horizon and never advances it (SPEC §8.3).
*/
#probe: RowFilter | null = null;
readonly #ticks: Ticks;
readonly #iteration: Iteration;
#caps: Uint32Array = new Uint32Array(0);
Expand All @@ -297,6 +303,10 @@ export class QueryResult<T extends readonly Term[] = readonly Term[]> {
}

public get count(): number {
const filter = this.#filter;
if (filter !== null) {
return this.#scanFiltered(filter, Infinity).length;
}
const archetypes = this[$archetypes];
let total = 0;
for (let i = 0; i < archetypes.length; i++) {
Expand All @@ -306,6 +316,10 @@ export class QueryResult<T extends readonly Term[] = readonly Term[]> {
}

public get isEmpty(): boolean {
const filter = this.#filter;
if (filter !== null) {
return this.#scanFiltered(filter, 1).length === 0;
}
const archetypes = this[$archetypes];
for (let i = 0; i < archetypes.length; i++) {
if (archetypes[i].rows !== 0) {
Expand All @@ -316,6 +330,11 @@ export class QueryResult<T extends readonly Term[] = readonly Term[]> {
}

public get first(): Entity | undefined {
const filter = this.#filter;
if (filter !== null) {
const hits = this.#scanFiltered(filter, 1);
return hits.length === 0 ? undefined : (hits[0] as Entity);
}
const archetypes = this[$archetypes];
for (let i = archetypes.length - 1; i >= 0; i--) {
const archetype = archetypes[i];
Expand All @@ -327,11 +346,21 @@ export class QueryResult<T extends readonly Term[] = readonly Term[]> {
}

public [Symbol.iterator](): Iterator<Entity> {
const filter = this.#filter;
if (filter !== null) {
return (this.#scanFiltered(filter, Infinity) as unknown as Iterable<Entity>)[
Symbol.iterator
]();
}
return new EntityIterator(this[$archetypes]);
}

/** A copy, so it survives the structural change it is being used to drive (SPEC §9). */
public entities(): Float64Array {
const filter = this.#filter;
if (filter !== null) {
return this.#scanFiltered(filter, Infinity);
}
const archetypes = this[$archetypes];
const out = new Float64Array(this.count);
let at = 0;
Expand All @@ -344,6 +373,38 @@ export class QueryResult<T extends readonly Term[] = readonly Term[]> {
return out;
}

/**
* The entities a tick-filtered walk would visit right now, in walk order,
* stopping after `limit` hits. Reads through the probe, so `each` still sees
* every one of them afterwards.
*/
#scanFiltered(filter: RowFilter, limit: number): Float64Array {
const probe = (this.#probe ??= RowFilter.of(this[$terms])!);
const hits: number[] = [];
if (!probe.peek(this.#ticks, filter.lastSeen)) {
return new Float64Array(0);
}
const archetypes = this[$archetypes];
for (let a = archetypes.length - 1; a >= 0 && hits.length < limit; a--) {
const archetype = archetypes[a];
const rows = archetype.rows;
if (rows === 0) {
continue;
}
probe.bind(archetype);
const { pageShift, pageMask } = archetype;
for (let row = rows - 1; row >= 0 && hits.length < limit; row--) {
const page = row >>> pageShift;
const i = row & pageMask;
const entity = archetype.entities[page][i] as Entity;
if (probe.accept(entity, page, i)) {
hits.push(entity);
}
}
}
return Float64Array.from(hits);
}

public each(fn: EachFn<T>): void {
const iteration = this.#iteration;
const frame = iteration.enter();
Expand Down
19 changes: 16 additions & 3 deletions src/core/sorted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,17 +279,26 @@ export class SortedQueryResult<T extends readonly Term[] = readonly Term[]> impl
base.attach(this);
}

// A tick-filtered view answers the reads through its own walk, which owns
// the filter its each() runs with; an unfiltered one keeps the O(1) answers.
public get count(): number {
return this.#base.count;
if (!this.#walk.filtered) {
return this.#base.count;
}
const view = this[$view];
return this.#walk.count(view.ensure(), view.length);
}

public get isEmpty(): boolean {
return this.#base.isEmpty;
return this.#walk.filtered ? this.first === undefined : this.#base.isEmpty;
}

public get first(): Entity | undefined {
const view = this[$view];
const entities = view.ensure();
if (this.#walk.filtered) {
return this.#walk.first(entities, view.length, 1);
}
return view.length === 0 ? undefined : (entities[0] as Entity);
}

Expand All @@ -316,7 +325,11 @@ export class SortedQueryResult<T extends readonly Term[] = readonly Term[]> impl
/** An ordered copy, safe to drive structural change with (SPEC §9). */
public entities(): Float64Array {
const view = this[$view];
return view.ensure().slice(0, view.length);
const entities = view.ensure();
if (this.#walk.filtered) {
return this.#walk.collect(entities, view.length, 1);
}
return entities.slice(0, view.length);
}

/**
Expand Down
20 changes: 20 additions & 0 deletions src/core/walk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,11 @@ export class RowFilter {
return new RowFilter(changed ?? NO_TRAITS, added ?? NO_IDS, removed ?? NO_IDS);
}

/** The tick this filter last consumed up to — the horizon its next `begin` will use. */
public get lastSeen(): number {
return this.#lastSeen;
}

/**
* Opens a run: fixes its horizon and resolves the removal records to handle
* sets. Returns false when a set is empty, so the conjunction cannot match
Expand All @@ -356,7 +361,22 @@ export class RowFilter {
public begin(ticks: Ticks): boolean {
this.#horizon = this.#lastSeen;
this.#lastSeen = ticks.tick;
return this.#open(ticks);
}

/**
* Opens a run that answers "what would `begin` accept right now?" without
* consuming anything: the horizon is taken as given and `lastSeen` is left
* alone, so `count` / `isEmpty` / `first` can be read ahead of `each` without
* eating its events. Use it on a filter nothing else walks with, so an
* in-flight `each` never finds its bound columns swapped from under it.
*/
public peek(ticks: Ticks, horizon: number): boolean {
this.#horizon = horizon;
return this.#open(ticks);
}

#open(ticks: Ticks): boolean {
const removed = this.#removed;
const removedSets = this.#removedSets;
for (let r = 0; r < removed.length; r++) {
Expand Down
3 changes: 2 additions & 1 deletion tests/accessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,8 @@ describe('set is a real write (§4.5, §8.1, §8.3)', () => {
px.get(e);

expect(calls).toBe(0);
expect(query.count).toBe(1);
expect(world.query(Position).count).toBe(1); // still there …
expect(query.isEmpty).toBe(true); // … and unchanged
let seen = 0;
query.each(() => seen++);
expect(seen).toBe(0);
Expand Down
Loading