From d1cc9bedaa9ad75393c5be5a225015dec5df2dfb Mon Sep 17 00:00:00 2001 From: Andy Aragon Date: Mon, 8 Jun 2026 00:26:33 -0700 Subject: [PATCH] feat(core): expose per-spec strides on bindColumns meta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pinned vec column hands its raw flat view through — row r's axes at [r*stride, (r+1)*stride) — so callers had to HARDCODE the arity (r*3), a silent mis-index waiting to happen if a vec3 later becomes a vec4. The eachChunk cursor already exposes the stride via stride(); bindColumns didn't. meta now carries strides: readonly number[] (slots-per-row per spec, in spec order — 1 for a scalar, N for a vecN), so a caller reads `const s = meta.strides[0]` ONCE outside the runner and indexes `view[r*s + axis]` without hardcoding. It's def-invariant (the vec arity is a field property, not an archetype one) and identity-stable per binding, read outside the hot loop, so the closure specialization the API exists for is untouched. Closes the last of the four deferred legs from the launch audit. --- packages/core/src/query/live-query.ts | 23 +++++++++++++++-------- packages/core/test/bind-columns.test.ts | 20 +++++++++++++------- packages/schema/src/index.ts | 6 ++++++ website/guide/performance.md | 9 +++++++++ 4 files changed, 43 insertions(+), 15 deletions(-) diff --git a/packages/core/src/query/live-query.ts b/packages/core/src/query/live-query.ts index 02f4aba..ba372f0 100644 --- a/packages/core/src/query/live-query.ts +++ b/packages/core/src/query/live-query.ts @@ -10,6 +10,7 @@ import type { ArchetypeId, + BoundColumnsMeta, ComponentDef, ComponentId, EntityHandle, @@ -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`) 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 @@ -421,13 +424,10 @@ export class LiveQuery { bindColumns( ...args: [ ...specs: ReadonlyArray, 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, 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()') @@ -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 @@ -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 } } diff --git a/packages/core/test/bind-columns.test.ts b/packages/core/test/bind-columns.test.ts index 504a4df..b034e47 100644 --- a/packages/core/test/bind-columns.test.ts +++ b/packages/core/test/bind-columns.test.ts @@ -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() diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 4b93db5..9c945b4 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -528,6 +528,12 @@ export type ColumnViews = { /** 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 diff --git a/website/guide/performance.md b/website/guide/performance.md index d075b9a..bb526cb 100644 --- a/website/guide/performance.md +++ b/website/guide/performance.md @@ -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