Skip to content
Merged
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
23 changes: 15 additions & 8 deletions packages/core/src/query/live-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import type {
ArchetypeId,
BoundColumnsMeta,
ComponentDef,
ComponentId,
EntityHandle,
Expand Down Expand Up @@ -408,7 +409,9 @@ export class LiveQuery {
* bind is free.
*
* Vec fields hand their raw view through: row `r` occupies `[r*stride, (r+1)*stride)` where the
* stride is the declared vec arity (`vec3()` → 3) — a compile-time constant in the caller's loop.
* stride is the declared vec arity (`vec3()` → 3). Hardcode it, or read `meta.strides[specIndex]`
* ONCE outside the hot loop (`const s = meta.strides[0]`) so the loop never repeats the lookup —
* the same value the `eachChunk` cursor exposes via `stride()`, def-invariant across archetypes.
* Rich fields (`'string'`/`object<T>`) carry no column and throw at bind time, as do row-filtered
* queries (a pinned runner cannot skip rows; `eachChunk` silently skips, but a silently-skipping
* pinned runner is a footgun) and specs naming a component the query does not REQUIRE (an
Expand All @@ -421,13 +424,10 @@ export class LiveQuery {
bindColumns(
...args: [
...specs: ReadonlyArray<readonly [ComponentDef<Schema>, string]>,
factory: (views: readonly TypedArray[], meta: { readonly count: number }) => () => void,
factory: (views: readonly TypedArray[], meta: BoundColumnsMeta) => () => void,
]
): () => void {
const factory = args[args.length - 1] as (
views: readonly TypedArray[],
meta: { readonly count: number },
) => () => void
const factory = args[args.length - 1] as (views: readonly TypedArray[], meta: BoundColumnsMeta) => () => void
const specs = args.slice(0, -1) as ReadonlyArray<readonly [ComponentDef<Schema>, string]>
if (this.compiled.rowFilters.length !== 0) {
throw new Error('bindColumns: row-filtered queries are not supported (a pinned runner cannot skip rows); use each()')
Expand Down Expand Up @@ -470,7 +470,7 @@ export class LiveQuery {
readonly cols: readonly Column[]
views: readonly TypedArray[]
runner: () => void
readonly meta: { readonly count: number }
readonly meta: BoundColumnsMeta
}

// Bindings are keyed by archetype id and PRESERVED across rebuilds: a rebuild only mints
Expand Down Expand Up @@ -500,10 +500,17 @@ export class LiveQuery {
return col
})
const views = cols.map((c) => c.view)
const meta = {
// Per-spec slots-per-row (1 scalar, N for vecN), in spec order — the same value the eachChunk
// cursor exposes via `c.stride(def, field)`. Def-invariant across archetypes (the vec arity is
// a property of the field), so every binding's array is equal; read it ONCE outside the hot
// loop (`const s = meta.strides[0]`) to index a vec view without hardcoding the arity, keeping
// the loop specialization-friendly.
const strides: readonly number[] = cols.map((c) => c.layout.stride)
const meta: BoundColumnsMeta = {
get count(): number {
return arch.count
},
strides,
}
return { arch, cols, views, runner: factory(views, meta), meta }
}
Expand Down
20 changes: 13 additions & 7 deletions packages/core/test/bind-columns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,13 +156,19 @@ describe('bindColumns iteration', () => {
const n = 8
for (let i = 0; i < n; i++) world.spawnWith(Transform)
const q = world.query(write(Transform))
const run = q.bindColumns([Transform, 'pos'], [Transform, 'w'], ([pos, w], meta) => () => {
const count = meta.count
for (let r = 0; r < count; r++) {
pos[r * 3] = r
pos[r * 3 + 1] = r * 10
pos[r * 3 + 2] = r * 100
w[r] = -r
const run = q.bindColumns([Transform, 'pos'], [Transform, 'w'], ([pos, w], meta) => {
// meta.strides[i] is the slots-per-row for spec i: 3 for the vec3 `pos`, 1 for the scalar `w`.
// Read ONCE outside the runner so the hot loop never repeats the lookup.
const s = meta.strides[0]!
expect(meta.strides).toEqual([3, 1])
return () => {
const count = meta.count
for (let r = 0; r < count; r++) {
pos[r * s] = r
pos[r * s + 1] = r * 10
pos[r * s + 2] = r * 100
w[r] = -r
}
}
})
run()
Expand Down
6 changes: 6 additions & 0 deletions packages/schema/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,12 @@ export type ColumnViews<Specs extends readonly ColumnSpec[]> = {
/** The per-binding meta box: identity-stable across rebinds; `count` is the live row count. */
export interface BoundColumnsMeta {
readonly count: number
/**
* Slots-per-row for each spec, in spec order: 1 for a scalar field, N for a `vecN`. Read ONCE
* outside the hot loop to index a vec view without hardcoding its arity — `const s = meta.strides[i]`,
* then `view[r * s + axis]`. The same value the {@link QueryChunk} cursor exposes via `stride()`.
*/
readonly strides: readonly number[]
}

// One reused chunk per matched hot archetype exposing raw SoA
Expand Down
9 changes: 9 additions & 0 deletions website/guide/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,15 @@ const run = q.bindColumns(
run() // call once per frame
```

For a `vec` field the view is the raw flat array — row `r`'s axes live at `[r * stride, (r+1) * stride)`,
where the stride is the arity you declared (`vec3` → 3). Read it once from `meta.strides[specIndex]`
rather than hardcoding the number, so a later `vec3` → `vec4` change can't silently mis-index:

```ts no-check
const s = meta.strides[0] // the first spec's slots-per-row
for (let r = 0; r < meta.count; r++) pos[r * s] += dx[r] * dt
```

Two requirements make this fast, and both are part of the contract rather than style:

- **Your loop must persist.** The speed comes from V8 treating the captured arrays as constants, and
Expand Down
Loading