From d2e36e8aac519626e91c81064558b063804f9c3c Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 22 Oct 2026 00:10:33 -0700 Subject: [PATCH 1/2] ecs: order on System.render, one paint-ordered table across archetypes Contract change (authorized): System.render's declaration takes `order?: Index<{ readonly sorted: true; readonly key: readonly Component.Key[] }>`. With it, the body's `tables` holds exactly one table whose `count` is every matching row across archetypes (has/not apply) and whose `bind` makes instance i the i-th of them in the index's order. Shader accessors keep their names and `fn name(row: u32)` signatures; `row` is the position in the order and `ecs_count()` the instance count. Gpu.wgsl emits, for an ordered system, the record `struct EcsTable { ecs_rows, ecs_order, ecs_tables, ecs_shift }` and an `ecs_address` helper; unordered output is unchanged. A non-sorted or unknown index is a type error and throws at creation; a run whose matching archetype holds more rows than 32 bits name beside its slot fails in its report. Implementation: an order block of one u32 per instance (slot above the record's shift, row below) and a tables block of each slot's column word addresses, both heap blocks through uploadIndexes. The order is listed and written only when the index's `changed` tick or a new `moved` tick (an entry relocated with its key unchanged) moves, or the matches grow; the tables block only when an address changes; the record only when a word does. An unchanged run writes nothing and allocates nothing. Numbers, chromium: gpu ordered render unchanged 50K 0.030-0.036 ms a run (unordered two tables 0.045-0.049); gpu ordered render one key 50K 0.268-0.294 ms (listing 50K entries 0.17 ms, 200 KB write 0.07 ms). Tests: render-order.browser.test.ts (two archetypes interleaved in depth, has/not, despawn row move, spawn, key change, a hidden entity shown, one table when nothing matches, writes only on change, ordered WGSL compiles), system.type-test.ts (sorted, hash, unknown and non-index orders), a creation check in create.test.ts. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 2 +- packages/ecs/followups.md | 6 - .../src/implementation/world/index-sorted.ts | 14 +- .../ecs/src/implementation/world/indexes.ts | 9 +- packages/ecs/src/implementation/world/perf.md | 4 + .../world/render-order.browser.test.ts | 216 ++++++++++++++++++ .../src/implementation/world/render-order.ts | 150 ++++++++++++ .../ecs/src/implementation/world/runner.ts | 19 ++ packages/ecs/src/interface/gpu/accessors.ts | 32 ++- packages/ecs/src/interface/gpu/wgsl.ts | 13 +- .../ecs/src/interface/indexes/create.test.ts | 5 + packages/ecs/src/interface/system/render.ts | 18 +- .../src/interface/system/system.type-test.ts | 27 +++ packages/ecs/src/perftest/render-order.ts | 91 ++++++++ .../src/perftest/suite.perf-browser-test.ts | 2 + 15 files changed, 580 insertions(+), 28 deletions(-) create mode 100644 packages/ecs/src/implementation/world/render-order.browser.test.ts create mode 100644 packages/ecs/src/implementation/world/render-order.ts create mode 100644 packages/ecs/src/perftest/render-order.ts diff --git a/packages/ecs/design.md b/packages/ecs/design.md index da7d33e8..0f729ee2 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -56,7 +56,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Subset errors** are reported at build time. An ESLint rule shares the same validator, so errors also show in the editor. - **GPU systems.** The built-in `gpuDevice` resource, `GPUDevice | null`, is the one device the world's GPU storage lives on, for compute and render alike: GPU buffers belong to one device, so a second device means a second world. It's an ordinary resource, given as `World.create({ gpuDevice: device })` or set later by a main-thread system; the world never destroys it. After each run the world follows it: a new device gets the GPU storage and `kernel: "gpu"` systems, after what's ahead on the old device is read back; with null, GPU kernels run on the CPU again. Render systems run only while there's a device, as if their `is` listed `gpuDevice`. A lost device makes the world set `gpuDevice` to null, as its own sync point, so nothing fails: GPU kernels run on the CPU from the CPU's copies, render systems are skipped, and systems watching `changed: [gpuDevice]` see it; GPU writes not yet read back are lost with the device. - **`kernel: "gpu"` declares a GPU kernel.** It runs as a WGSL compute shader compiled from the same Program IR as WASM in a world with a device, and as a `"cpu"` kernel in one without, so it stays portable. Each rule is checked as early as it can be known: types rule out lookups, change filters and commands; the build rejects loops and anything that keeps the body from lowering to a Program; registration rejects f64 or byte values, in every world, since such a declaration can never hold. - - **Render systems** (`System.render`) run once per run on the main thread in schedule order, read-only. They record into the run's encoder over the GPU copies of the components their accessors name, which are what they read, one table per matched archetype. `Gpu.wgsl(system)` returns their shaders' declarations: the world's bind group at the group they name and a function per component (`fn position(row: u32) -> vec3`), from schemas alone, so shaders can be written before a world exists. Pipelines take `Gpu.layout(device)`, one object per device, so they can be built before a world exists; bind groups and buffers stay the world's, so they can be replaced as storage grows. + - **Render systems** (`System.render`) run once per run on the main thread in schedule order, read-only. They record into the run's encoder over the GPU copies of the components their accessors name, which are what they read, one table per matched archetype; with `order`, a sorted index, one table of every matching row in its order, whose accessors resolve an instance's position through an order block of slot and row to its archetype's columns. `Gpu.wgsl(system)` returns their shaders' declarations: the world's bind group at the group they name and a function per component (`fn position(row: u32) -> vec3`), from schemas alone, so shaders can be written before a world exists. Pipelines take `Gpu.layout(device)`, one object per device, so they can be built before a world exists; bind groups and buffers stay the world's, so they can be replaced as storage grows. - **Residency:** a separate GPU heap holds copies of only the storages GPU systems use. Each copy tracks rows the CPU changed since its upload and rows the GPU wrote since its readback. GPU systems upload what the CPU changed first, in runs of the chunks it stamped since the last upload: the components GPU systems use keep ticks, and kernels writing them keep their chunk stamps. CPU systems read back what the GPU wrote first. A sync point reads back only archetypes whose existing rows it moves or writes (despawns, adds, removes); spawns only append rows, which are uploaded alone, and a copy that outgrows its block moves on the GPU. GPU-to-GPU chains and rendering never copy back. - **Change filters:** a GPU kernel's writes stamp each written column once with the run's tick, and `changed` filters pass a whole column when its stamp is newer than their `lastRun`, so writes on the GPU cost nothing per row on the CPU. - **Ordering:** a queue write lands before recorded but unsubmitted work, so recorded work is submitted before each upload. Each run's encoder is submitted at its end, before the sync point; `run` resolves then, not when the GPU finishes. diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 566e8c1e..a0067d87 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -35,12 +35,6 @@ Open items awaiting a decision or work. Remove an item once it's decided or done per-row object such as a texture's bind group, and a row body can't learn its GPU row. A consumer drawing an image per row keeps its own instance buffer, 4,000 draws in 2.1 ms a tick. Possible: tables giving their entities in GPU row order. Not needed until a consumer's profile says so. -- **Paint order across render tables:** a render system draws each matching archetype as its own table in row order, - so 2D content of several kinds (shapes, images, paths) can't be blended back to front without the consumer keeping - its own sorted instance list. Proposal: `order: index` on `System.render`, giving one table spanning its archetypes - whose `bind` sets a buffer of the index's rows in sorted order with each archetype's column base addresses; every - column already lives in one heap, so accessors resolve a sorted instance to its row. Not needed while consumers draw - every kind with one shader from their own list. - **Report retention:** the last 256 runs keep their reports (`retainedRuns` in `reports.ts`), a fixed internal constant; a later run takes the oldest one's entity. Recommended: keep it internal until a consumer needs another; then a world option or resource. diff --git a/packages/ecs/src/implementation/world/index-sorted.ts b/packages/ecs/src/implementation/world/index-sorted.ts index 6cfee390..b0a3dcfc 100644 --- a/packages/ecs/src/implementation/world/index-sorted.ts +++ b/packages/ecs/src/implementation/world/index-sorted.ts @@ -585,12 +585,16 @@ const split = (table: SortedTable, k: number, tick: number): void => { reindexFrom(table, k, table.blocks); }; -/** Updates where entity index `entity` is: its archetype and row, at its entry. */ -export const relocateSorted = (table: SortedTable, entity: number, archetype: number, row: number): void => { +/** Updates where entity index `entity` is: its archetype and row, at its entry. Returns whether either changed. */ +export const relocateSorted = (table: SortedTable, entity: number, archetype: number, row: number): boolean => { const base = entryOf(table, entity); - if (base < 0) return; - table.entries[base + table.words + 1] = archetype; - table.entries[base + table.words + 2] = row; + if (base < 0) return false; + const at = base + table.words; + const { entries } = table; + if (entries[at + 1] === archetype && entries[at + 2] === row) return false; + entries[at + 1] = archetype; + entries[at + 2] = row; + return true; }; /** diff --git a/packages/ecs/src/implementation/world/indexes.ts b/packages/ecs/src/implementation/world/indexes.ts index df866c16..82c804e2 100644 --- a/packages/ecs/src/implementation/world/indexes.ts +++ b/packages/ecs/src/implementation/world/indexes.ts @@ -51,6 +51,11 @@ export type WorldIndex = { * it was built; 0 before. */ changed: number; + /** + * For a sorted index, the tick of the last refresh at which an entry moved to another archetype or row with its key + * and place unchanged, or it was built; 0 before. + */ + moved: number; /** By watched component: how many lookups watch it, and the tick its ticks were read up to. */ readonly watchers: Map; readonly scanned: Map; @@ -124,6 +129,7 @@ export const acquireIndex = (state: State, definition: IndexDefinition): WorldIn position: -1, refreshed: 0, changed: 0, + moved: 0, watchers: new Map(), scanned: new Map(), hash: sorted ? null : createHashTable(state.memory, words), @@ -434,6 +440,7 @@ const finishRefresh = async (state: State, index: WorldIndex, end: number, tick: /** Builds an index again: in phases, across the pool when there is one, for a large hash index keyed by structs; else here. */ const rebuildAny = (state: State, index: WorldIndex, tick: number, failures: Failure[], pool: Pool | null): Promise | null => { index.changed = tick; + index.moved = tick; const phased = rebuildPhased(state, index, tick, failures, pool); if (phased !== false) return phased; rebuild(state, index, tick, failures); @@ -597,7 +604,7 @@ const patchSorted = (state: State, index: WorldIndex, sorted: SortedTable, count const fresh = index.objects && unknownValue(index, key); if (fresh) encodeRow(index, archetype, row, key, 0, true); if (old >= 0 && !fresh && sameStoredKey(sorted, old, key, 0)) { - relocateSorted(sorted, entity, archetypeId, row); + if (relocateSorted(sorted, entity, archetypeId, row)) index.moved = tick; continue; } if (objects && !fresh) holdWords(index, key, 0, true); diff --git a/packages/ecs/src/implementation/world/perf.md b/packages/ecs/src/implementation/world/perf.md index ac28b9a1..78e66f7e 100644 --- a/packages/ecs/src/implementation/world/perf.md +++ b/packages/ecs/src/implementation/world/perf.md @@ -142,6 +142,10 @@ GPU copies uploading only the chunks CPU writes changed, against `a4eef10e`, chr - **An upload writes each run of adjacent chunks stamped since its last upload** or holding rows a sync point appended or disturbed, clamped to the row count; a column with GPU writes the CPU hasn't read back writes only those rows. A whole-column stamp (`columnChanged`) uploads nothing: only GPU kernels make one, and their writes are on the GPU already. - **A WASM kernel's column addresses are kept per load as well as per archetype version:** a kernel loaded again to stamp a component that already kept ticks went on passing the muted column's address, 0, and wrote its stamps over the entity table there. +Render systems with `order`, against `9daaa3f8`, chromium, three runs each: new `gpu ordered render unchanged 50K`, 50K shapes and images, two archetypes interleaved in depth, drawn as one table in a sorted index's order with nothing changed, 0.030–0.036 ms a run, against 0.045–0.049 ms for the same 50K drawn without `order` as two tables, which writes both tables' records each run. New `gpu ordered render one key 50K`, a target system moving one item to another depth and then the ordered draw, 0.268–0.294 ms a run, against 0.042–0.050 ms unordered: listing the 50K entries in order costs 0.167–0.175 ms (3.4 ns an entry) and writing the 200 KB block 0.067–0.070 ms, timed inside the run. +- **The order block is listed and written only when the index's entries changed or moved rows,** by the index's `changed` tick and a `moved` tick its refresh stamps when it relocates an entry with its key unchanged, or when the matches grew; the tables block only when a column's address changed; the record only when one of its four words did. An unchanged run writes nothing to the queue and allocates nothing: the table, its `bind`, the tables list and the context are reused. +- **An entry is one u32:** the matching archetype's slot in the high bits, as few as the matches need and at least one, and its row in the rest, so the shader masks by the record's shift. + ## Where the gains came from Step figures marked (vitest) are from the node vitest lane: read them as relative. diff --git a/packages/ecs/src/implementation/world/render-order.browser.test.ts b/packages/ecs/src/implementation/world/render-order.browser.test.ts new file mode 100644 index 00000000..3bed86a2 --- /dev/null +++ b/packages/ecs/src/implementation/world/render-order.browser.test.ts @@ -0,0 +1,216 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import type { Entity } from "../../interface/entity/entity.js"; +import { Gpu } from "../../interface/gpu/gpu.js"; +import { Index } from "../../interface/indexes/index-type.js"; +import { gpuDevice } from "../../interface/resources/gpu-device.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld } from "./create.js"; +import { runReported } from "./reports.test-helper.js"; + +const depth = Symbol("renderOrderTest/depth"); +const code = Symbol("renderOrderTest/code"); +const shape = Symbol("renderOrderTest/shape"); +const image = Symbol("renderOrderTest/image"); +const size = Symbol("renderOrderTest/size"); +const visible = Symbol("renderOrderTest/visible"); +const hidden = Symbol("renderOrderTest/hidden"); + +declare module "@adobe/ecs" { + export interface Components { + [depth]: { type: number }; + [code]: { type: number }; + [shape]: { type: true }; + [image]: { type: true }; + [size]: { type: readonly [number, number, number] }; + [visible]: { type: true }; + [hidden]: { type: true }; + } +} + +Component.register(depth, { type: { type: "number", precision: 1 } }); +Component.register(code, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); +Component.register(shape, { type: { const: true } }); +Component.register(image, { type: { const: true } }); +Component.register(size, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } }); +Component.register(visible, { type: { const: true } }); +Component.register(hidden, { type: { const: true } }); + +const byDepth = Index.create({ name: "renderOrderTest/byDepth", key: [depth], sorted: true }); + +const device = async (): Promise => { + const adapter = await navigator.gpu.requestAdapter(); + if (adapter === null) throw new Error("No WebGPU adapter."); + return adapter.requestDevice(); +}; + +const count = 2000; +const spawned: Entity[] = []; + +/** + * Shapes and images interleaved in depth, spawned in reverse depth order, so neither spawn nor row order is paint + * order. Every tenth is hidden or not visible, which the render system filters out, and plain depths match nothing. + */ +const spawn = System.create({ name: "renderOrderTest/spawn" }, ({ commands }) => { + for (let i = count - 1; i >= 0; i--) { + if (i % 10 === 3) spawned[i] = commands.spawn([depth, code, shape, visible, hidden])({ [depth]: i, [code]: i, [shape]: true, [visible]: true, [hidden]: true }); + else if (i % 10 === 7) spawned[i] = commands.spawn([depth, code, image])({ [depth]: i, [code]: i, [image]: true }); + else if (i % 2 === 0) spawned[i] = commands.spawn([depth, code, shape, visible])({ [depth]: i, [code]: i, [shape]: true, [visible]: true }); + else spawned[i] = commands.spawn([depth, code, image, size, visible])({ [depth]: i, [code]: i, [image]: true, [size]: [i, i, i], [visible]: true }); + } + commands.spawn([depth])({ [depth]: -1 }); +}); + +/** The codes a frame should draw, in paint order. */ +const drawn = (codes: Iterable): number[] => [...codes].filter(i => i % 10 !== 3 && i % 10 !== 7).sort((a, b) => a - b); + +describe("render systems with an order", () => { + it("draw every matching archetype as one table, in the index's order, through the same accessors", async () => { + const own = await device(); + const output = own.createBuffer({ size: (count + 16) * 8, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST }); + const tableCounts: number[][] = []; + const draw = System.render({ + name: "renderOrderTest/draw", + components: { has: [visible], not: [hidden] }, + order: byDepth, + shader: { accessors: { paintDepth: depth, paintCode: code } }, + }, ({ encoder, tables }) => { + tableCounts.push(tables.map(table => table.count)); + encoder.clearBuffer(output); + const pass = encoder.beginComputePass(); + pass.setPipeline(pipeline); + pass.setBindGroup(1, outputGroup); + for (const table of tables) { + table.bind(pass); + pass.dispatchWorkgroups(Math.ceil(table.count / 64)); + } + pass.end(); + }); + const outputLayout = own.createBindGroupLayout({ entries: [{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } }] }); + const outputGroup = own.createBindGroup({ layout: outputLayout, entries: [{ binding: 0, resource: { buffer: output } }] }); + const module = own.createShaderModule({ + code: `${Gpu.wgsl(draw)} +@group(1) @binding(0) var output: array; +@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3u) { + if (id.x >= ecs_count()) { return; } + output[id.x] = vec2u(u32(paintDepth(id.x)), paintCode(id.x)); +}`, + }); + expect((await module.getCompilationInfo()).messages.filter(message => message.type === "error")).toEqual([]); + const pipeline = own.createComputePipeline({ layout: own.createPipelineLayout({ bindGroupLayouts: [Gpu.layout(own), outputLayout] }), compute: { module, entryPoint: "main" } }); + + /** What the last frame drew: depth and code per instance. */ + const read = async (): Promise => { + const staging = own.createBuffer({ size: output.size, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }); + const encoder = own.createCommandEncoder(); + encoder.copyBufferToBuffer(output, 0, staging, 0, output.size); + own.queue.submit([encoder.finish()]); + await staging.mapAsync(GPUMapMode.READ); + const words = new Uint32Array(staging.getMappedRange().slice(0)); + staging.destroy(); + const instances = tableCounts.at(-1)?.[0] ?? 0; + return Array.from({ length: instances }, (_, i) => [words[2 * i], words[2 * i + 1]]); + }; + const frame = async (): Promise => expect((await runReported(world, draw)).failures).toEqual([]); + + const world = createWorld({ [gpuDevice]: own }); + await world.run(spawn); + await frame(); + const codes = new Set(Array.from({ length: count }, (_, i) => i)); + let expected = drawn(codes); + expect(tableCounts.at(-1)).toEqual([expected.length]); + expect(await read()).toEqual(expected.map(i => [i, i])); + + // A despawn moves another shape into its row, a spawn adds an image between two others, and a key moves a shape + // to the back; a frame after each draws the new order. + const despawn = System.create({ name: "renderOrderTest/despawn" }, ({ commands }) => commands.despawn(spawned[count - 2])); + await world.run(despawn); + codes.delete(count - 2); + await frame(); + expected = drawn(codes); + expect(await read()).toEqual(expected.map(i => [i, i])); + + const addImage = System.create({ name: "renderOrderTest/addImage" }, ({ commands }) => { + commands.spawn([depth, code, image, size, visible])({ [depth]: 500.5, [code]: 5001, [image]: true, [size]: [0, 0, 0], [visible]: true }); + }); + await world.run(addImage); + await frame(); + const withImage = expected.flatMap(i => i === 500 ? [[i, i], [500, 5001]] : [[i, i]]); + expect(await read()).toEqual(withImage); + + const toBack = System.create({ name: "renderOrderTest/toBack", components: { read: [code], write: [depth] } }, row => { + if (row[code] === 10) row[depth] = count + 5; + }); + await world.run(toBack); + await frame(); + const atBack = [...withImage.filter(([, c]) => c !== 10), [count + 5, 10]]; + expect(await read()).toEqual(atBack); + + // Showing a hidden shape adds it to the one table, in its place. + const show = System.create({ name: "renderOrderTest/show" }, ({ commands }) => commands.remove(spawned[13], hidden)); + await world.run(show); + await frame(); + const shown = atBack.flatMap(([d, c]) => d === 12 ? [[d, c], [13, 13]] : [[d, c]]); + expect(await read()).toEqual(shown); + expect(tableCounts.every(counts => counts.length === 1)).toBe(true); + await world.dispose(); + }); + + it("write their order only when the index's entries change or move", async () => { + const own = await device(); + const draw = System.render({ name: "renderOrderTest/writes", components: { has: [visible], not: [hidden] }, order: byDepth, shader: { accessors: { paintDepth: depth } } }, () => {}); + const world = createWorld({ [gpuDevice]: own }); + await world.run(spawn); + await world.run(draw); + const instances = drawn(Array.from({ length: count }, (_, i) => i)).length; + // Row uploads and block writes pass a size; record writes don't. + const sizes: number[] = []; + const { queue } = own; + const write = queue.writeBuffer.bind(queue); + let records = 0; + queue.writeBuffer = (buffer, offset, data, dataOffset, size) => { + if (size === undefined) records++; + else sizes.push(size); + write(buffer, offset, data, dataOffset, size); + }; + await world.run(draw); + expect([sizes, records]).toEqual([[], 0]); + // A new key writes its chunk of the column and the order, in elements; the record stays. + const toBack = System.create({ name: "renderOrderTest/toBackOnce", components: { read: [code], write: [depth] } }, row => { + if (row[code] === 10) row[depth] = count + 5; + }); + await world.run(toBack); + await world.run(draw); + expect(sizes.slice(1)).toEqual([instances]); + expect(records).toBe(0); + sizes.length = 0; + await world.run(draw); + expect(sizes).toEqual([]); + await world.dispose(); + }); + + it("give a shader one table when nothing matches", async () => { + const own = await device(); + let tables: readonly System.RenderTable[] = []; + const draw = System.render({ name: "renderOrderTest/empty", components: { has: [hidden, image] }, order: byDepth, shader: { accessors: { paintDepth: depth } } }, context => { tables = context.tables; }); + const world = createWorld({ [gpuDevice]: own }); + await world.run(spawn); + expect((await runReported(world, draw)).failures).toEqual([]); + expect(tables.map(table => table.count)).toEqual([0]); + await world.dispose(); + }); + + it("declare their ordered accessors, which compile", async () => { + const own = await device(); + const draw = System.render({ name: "renderOrderTest/declared", order: byDepth, shader: { group: 2, accessors: { paintDepth: depth, paintSize: size } } }, () => {}); + const code = Gpu.wgsl(draw); + expect(code).toContain("fn paintDepth(row: u32) -> f32"); + expect(code).toContain("fn paintSize(row: u32) -> vec3"); + expect(code).toContain("@group(2) @binding(1) var ecs_table: EcsTable;"); + // An instance is its position in the order, which the order block maps to a table and a row. + expect(code).toContain("struct EcsTable { ecs_rows: u32, ecs_order: u32, ecs_tables: u32, ecs_shift: u32 }"); + const module = own.createShaderModule({ code: `${code}\n@compute @workgroup_size(1) fn main() { _ = paintDepth(0u); _ = paintSize(0u); _ = ecs_count(); }` }); + expect((await module.getCompilationInfo()).messages.filter(message => message.type === "error")).toEqual([]); + }); +}); diff --git a/packages/ecs/src/implementation/world/render-order.ts b/packages/ecs/src/implementation/world/render-order.ts new file mode 100644 index 00000000..607755d9 --- /dev/null +++ b/packages/ecs/src/implementation/world/render-order.ts @@ -0,0 +1,150 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { RenderContext, RenderTable } from '../../interface/system/render.js'; +import type { Archetype } from './archetype.js'; +import type { GpuEngine } from './gpu.js'; +import type { Ordered } from './index-order.js'; +import { blockBase } from './index-sorted.js'; +import type { WorldIndex } from './indexes.js'; + +// A render system with `order` draws one table: instance `i` is the i-th matching entity in its sorted index's order. +// Its record is the instance count, the word addresses of its order block and tables block, and the bits a slot is +// shifted by. The order block holds, by position, a matched archetype's slot above `shift` bits and its row below +// them; the tables block holds, by slot, the word address of each accessor's column. The order block is built and +// written only when the index's entries changed or moved, or the matches grew; the tables block only when a column's +// address changed; the record only when one of its words did. + +/** The table of a render system's run and its context, both reused. */ +type MutableTable = { count: number; bind: (pass: GPURenderPassEncoder | GPUComputePassEncoder) => void }; +type MutableContext = { device: GPUDevice; encoder: GPUCommandEncoder; tables: readonly RenderTable[] }; + +export type OrderedRender = { + /** + * Uploads what the run's one table reads; returns null, or the reason it can't be drawn: more matched archetypes + * and rows than an entry's 32 bits hold. + */ + readonly prepare: (gpu: GpuEngine, matches: readonly Archetype[], tick: number) => string | null; + /** The body's context, once the work recorded before it is submitted. */ + readonly context: (gpu: GpuEngine) => RenderContext; +}; + +/** + * `columns` are the accessors' component ids, in accessor order; `group` the bind group the shader declares; `key` + * the prefix of the runner's GPU records. + */ +export const createOrderedRender = (index: WorldIndex, ordered: Ordered, columns: readonly number[], group: number, key: string): OrderedRender => { + const width = columns.length; + const orderKey = `${key}order`; + const tablesKey = `${key}tables`; + const recordKey = `${key}ordered`; + let entries = new Int32Array(64); + let instances = 0; + let tables = new Int32Array(Math.max(1, width)); + /** What the order block was last built from: the index's ticks, the matches, and the GPU it was written to. */ + let builtChanged = -1; + let builtMoved = -1; + let builtMatches = -1; + let builtGpu: GpuEngine | null = null; + let shift = 31; + const words = new Uint32Array(4); + let recorded = false; + const offsets = [0]; + let bound: GpuEngine | null = null; + const table: MutableTable = { + count: 0, + bind: pass => { + if (bound !== null) pass.setBindGroup(group, bound.renderGroup(), offsets); + }, + }; + const list: readonly RenderTable[] = [table]; + let context: MutableContext | null = null; + + /** Lists the index's entries of matched archetypes, in its order, as slot and row. */ + const build = (matches: readonly Archetype[]): void => { + const sorted = index.sorted; + instances = 0; + if (sorted === null) return; + if (entries.length < sorted.size) entries = new Int32Array(Math.max(sorted.size, 2 * entries.length)); + const owners = ordered.owners(matches); + const known = owners.length; + const slotShift = shift; + const { order, lengths, stride, words: keyWords, blocks } = sorted; + const sortedEntries = sorted.entries; + for (let k = 0; k < blocks; k++) { + const block = order[k]; + const base = blockBase(sorted, block) + keyWords; + for (let at = base, end = base + lengths[block] * stride; at < end; at += stride) { + const archetype = sortedEntries[at + 1]; + const owner = archetype < known ? owners[archetype] : -1; + if (owner < 0) continue; + entries[instances++] = (owner << slotShift) | sortedEntries[at + 2]; + } + } + }; + + const prepare = (gpu: GpuEngine, matches: readonly Archetype[], tick: number): string | null => { + const slots = matches.length; + if (gpu !== builtGpu) { + builtGpu = gpu; + builtChanged = -1; + recorded = false; + } + // The tables block: every column's address, uploading rows the CPU changed first. + if (tables.length < slots * width) { + const grown = new Int32Array(Math.max(slots * width, 2 * tables.length)); + grown.set(tables); + tables = grown; + } + let addressed = false; + let most = 0; + for (let slot = 0; slot < slots; slot++) { + const archetype = matches[slot]; + const { count } = archetype; + if (count > most) most = count; + for (let k = 0; k < width; k++) { + const storage = count === 0 ? undefined : archetype.storageById[columns[k]]; + const address = storage === undefined ? 0 : gpu.upload(archetype, storage, tick); + const at = slot * width + k; + if (tables[at] !== address) { + tables[at] = address; + addressed = true; + } + } + } + if (slots !== builtMatches) { + // At least one bit of slot, so a row's mask is never 32 bits. + const slotBits = Math.max(1, Math.ceil(Math.log2(slots))); + shift = 32 - slotBits; + builtMatches = slots; + builtChanged = -1; + } + if (most > 2 ** shift) return `with ${slots} matching archetypes an ordered table names at most ${2 ** shift} rows of each, and one holds ${most}`; + const rebuilt = index.changed !== builtChanged || index.moved !== builtMoved; + if (rebuilt) { + build(matches); + builtChanged = index.changed; + builtMoved = index.moved; + } + const orderAddress = gpu.uploadIndexes(orderKey, entries, instances, rebuilt); + const tablesAddress = gpu.uploadIndexes(tablesKey, tables, slots * width, addressed); + if (!recorded || words[0] !== instances || words[1] !== orderAddress || words[2] !== tablesAddress || words[3] !== shift) { + words[0] = instances; + words[1] = orderAddress; + words[2] = tablesAddress; + words[3] = shift; + offsets[0] = gpu.record(recordKey, words); + recorded = true; + } + bound = gpu; + table.count = instances; + return null; + }; + + const contextOf = (gpu: GpuEngine): RenderContext => { + if (context === null) return context = { device: gpu.device, encoder: gpu.encoder(), tables: list }; + context.device = gpu.device; + context.encoder = gpu.encoder(); + return context; + }; + + return { prepare, context: contextOf }; +}; diff --git a/packages/ecs/src/implementation/world/runner.ts b/packages/ecs/src/implementation/world/runner.ts index 4dc78bc2..c0ce8cd4 100644 --- a/packages/ecs/src/implementation/world/runner.ts +++ b/packages/ecs/src/implementation/world/runner.ts @@ -33,6 +33,7 @@ import { acquireLookups, type IndexLookups, type IndexUse } from './indexes.js'; import { createOrdered, type Ordered, type Passing } from './index-order.js'; import { blockBase, type SortedTable } from './index-sorted.js'; import type { Since } from './index-lookups.js'; +import { createOrderedRender } from './render-order.js'; /** * One unit of a system's work for a run: the commands it issued, or for a worker's chunk its segment of the worker's @@ -232,6 +233,9 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { }); const accessors = declaration.shader === undefined ? [] : accessorsOf(declaration.shader); if (typeof accessors === "string") return failPlacement(`can't render: ${accessors}`); + /** A render system's one table in its `order` index's order. */ + const orderedRender = leaf.kind !== "render" || indexLookups.ordered === null || ordered === null ? null + : createOrderedRender(indexLookups.ordered, ordered, accessors.map(accessor => idOf(state, accessor.component)), declaration.shader?.group ?? 0, `${leafNumber}:`); const touches = [...new Set([...query?.read ?? [], ...query?.write ?? []])].map(key => idOf(state, key)); const writes = (query?.write ?? []).map(key => idOf(state, key)); const looks = [...new Set([...Object.values(declaration.lookup ?? {}).flatMap(lookup => lookup.read ?? []), ...declaration.up?.read ?? []])].map(key => idOf(state, key)); @@ -831,6 +835,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { if (gpu === null) return failPlacement("needs a world with a gpu device"); if (typeof accessors === "string") return; holdings.gpu = gpu; + if (orderedRender !== null) return runOrderedRender(gpu, tick, orderedRender); const group = declaration.shader?.group ?? 0; const tables = matches.filter(archetype => archetype.count > 0).map(archetype => { words[0] = archetype.count; @@ -852,6 +857,20 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { } }; + /** A render system with `order`: one table, nothing allocated while nothing it reads changed. */ + const runOrderedRender = (gpu: GpuEngine, tick: number, render: NonNullable): void => { + const failure = render.prepare(gpu, matches, tick); + if (failure !== null) return failPlacement(`can't render: ${failure}`); + gpu.submit(); + try { + system.body(render.context(gpu), context.value); + } + catch (error) { + gpu.discard(); + throw error; + } + }; + const holdings: Holdings = { removed: false, released: false, partials: null, up: up ?? ordered, directories: kernel?.directories ?? [], indexes: indexLookups, gpu: null, prefix: `${leafNumber}:`, afterSync: partialCount > 0 ? new WeakRef(sizeSlots) : null, remote: null, diff --git a/packages/ecs/src/interface/gpu/accessors.ts b/packages/ecs/src/interface/gpu/accessors.ts index d628fad3..9d40716d 100644 --- a/packages/ecs/src/interface/gpu/accessors.ts +++ b/packages/ecs/src/interface/gpu/accessors.ts @@ -28,14 +28,15 @@ const reserved = new Set(("alias break case const const_assert continue continui /** * Accessors for a render system's shader, in `accessors` order, or the reason one can't be read on the GPU: scalars - * and tuples of two to four scalars of one type, laid out in 4-byte words. + * and tuples of two to four scalars of one type, laid out in 4-byte words. An `ordered` system's accessors take a + * row's position in its order, which `ecs_address` resolves to a table's column. */ -export const accessorsOf = (shader: Shader): readonly Accessor[] | string => { +export const accessorsOf = (shader: Shader, ordered = false): readonly Accessor[] | string => { const accessors: Accessor[] = []; const entries = Object.entries(shader.accessors); // A table record is a row count, then a word address per accessor. if (entries.length > 63) return "it names more than 63 accessors"; - for (const [name, key] of entries) { + for (const [column, [name, key]] of entries.entries()) { const component = labelOf(key); if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name) || name.startsWith("ecs_") || reserved.has(name)) return `"${name}" isn't usable as a WGSL function name`; const declaration = declarationOf(key); @@ -48,26 +49,41 @@ export const accessorsOf = (shader: Shader): readonly Accessor[] | string => { return `"${component}" isn't laid out in 4-byte words of f32, i32, u32 or booleans; give it a "storage" or "vertex" layout or 4-byte scalars`; } const words = stride / 4; - const word = (leaf: Leaf): string => `ecs_table.ecs_${name} + row * ${words}u + ${leaf.offset / 4}u`; + const word = ordered + ? (leaf: Leaf): string => `ecs_base + ${leaf.offset / 4}u` + : (leaf: Leaf): string => `ecs_table.ecs_${name} + row * ${words}u + ${leaf.offset / 4}u`; + const base = ordered ? `let ecs_base = ecs_address(row, ${column}u, ${words}u); ` : ""; const [first] = leaves; if (leaves.length === 1 && first.path.length === 0) { - accessors.push({ name, component: key, code: `fn ${name}(row: u32) -> ${types[0]} { return ${readOf(first, word(first))}; }` }); + accessors.push({ name, component: key, code: `fn ${name}(row: u32) -> ${types[0]} { ${base}return ${readOf(first, word(first))}; }` }); continue; } const tuple = leaves.length >= 2 && leaves.length <= 4 && leaves.every((leaf, i) => leaf.path.length === 1 && leaf.path[0] === i && types[i] === types[0]); if (!tuple) return `"${component}" isn't a scalar or a tuple of two to four scalars of one type`; const items = leaves.map(leaf => readOf(leaf, word(leaf))).join(", "); - accessors.push({ name, component: key, code: `fn ${name}(row: u32) -> vec${leaves.length}<${types[0]}> { return vec${leaves.length}<${types[0]}>(${items}); }` }); + accessors.push({ name, component: key, code: `fn ${name}(row: u32) -> vec${leaves.length}<${types[0]}> { ${base}return vec${leaves.length}<${types[0]}>(${items}); }` }); } return accessors; }; /** The WGSL declarations of a render system's bind group and accessors. */ -export const declarationsOf = (group: number, accessors: readonly Accessor[]): string => [ +export const declarationsOf = (group: number, accessors: readonly Accessor[], ordered = false): string => [ `@group(${group}) @binding(0) var ecs_heap: array;`, - `struct EcsTable { ecs_rows: u32, ${accessors.map(accessor => `ecs_${accessor.name}: u32`).join(", ")} }`, + ordered + ? "struct EcsTable { ecs_rows: u32, ecs_order: u32, ecs_tables: u32, ecs_shift: u32 }" + : `struct EcsTable { ecs_rows: u32, ${accessors.map(accessor => `ecs_${accessor.name}: u32`).join(", ")} }`, `@group(${group}) @binding(1) var ecs_table: EcsTable;`, `fn ecs_count() -> u32 { return ecs_table.ecs_rows; }`, + ...ordered ? [orderedAddress(accessors.length)] : [], ...accessors.map(accessor => accessor.code), "", ].join("\n"); + +/** + * The word address of a column's value at a position of an ordered system's order. The order block holds, by position, + * a table's slot above `ecs_shift` bits and its row below them; the tables block holds, by slot, a word address per + * accessor. + */ +const orderedAddress = (columns: number): string => + "fn ecs_address(row: u32, column: u32, words: u32) -> u32 { let ecs_entry = ecs_heap[ecs_table.ecs_order + row]; " + + `return ecs_heap[ecs_table.ecs_tables + (ecs_entry >> ecs_table.ecs_shift) * ${columns}u + column] + (ecs_entry & ((1u << ecs_table.ecs_shift) - 1u)) * words; }`; diff --git a/packages/ecs/src/interface/gpu/wgsl.ts b/packages/ecs/src/interface/gpu/wgsl.ts index b9f7a39c..8def4790 100644 --- a/packages/ecs/src/interface/gpu/wgsl.ts +++ b/packages/ecs/src/interface/gpu/wgsl.ts @@ -7,15 +7,18 @@ import { accessorsOf, declarationsOf } from './accessors.js'; /** * The WGSL a render system's shaders include: its bind group at `shader.group` (the heap and its table record), and a * function per accessor returning that component of a row, such as `fn position(row: u32) -> vec3`, plus - * `ecs_count()`. Depends only on registered schemas, so pipelines can be built before any world exists. + * `ecs_count()`. For a system with `order`, `row` is an instance's position in its order and `ecs_count()` the number + * of instances. Depends only on registered schemas, so pipelines can be built before any world exists. * Throws if the system isn't a render system or a component can't be read on the GPU. */ export const wgsl = (system: System): string => { const definition = definitionOf(system); - const shader = definition?.kind === "render" ? definition.declaration.shader : undefined; - if (shader === undefined) throw new Error(`System "${system.name}" isn't a render system.`); + const declaration = definition?.kind === "render" ? definition.declaration : undefined; + const shader = declaration?.shader; + if (declaration === undefined || shader === undefined) throw new Error(`System "${system.name}" isn't a render system.`); const { name } = system; - const accessors = accessorsOf(shader); + const ordered = declaration.order !== undefined; + const accessors = accessorsOf(shader, ordered); if (typeof accessors === "string") throw new Error(`Render system "${name}": ${accessors}.`); - return declarationsOf(shader.group ?? 0, accessors); + return declarationsOf(shader.group ?? 0, accessors, ordered); }; diff --git a/packages/ecs/src/interface/indexes/create.test.ts b/packages/ecs/src/interface/indexes/create.test.ts index c72b27c0..8ab3007d 100644 --- a/packages/ecs/src/interface/indexes/create.test.ts +++ b/packages/ecs/src/interface/indexes/create.test.ts @@ -143,4 +143,9 @@ describe("systems ordering their rows by an index", () => { expect(() => untypedSystem({ name: "bad", components: { write: [amount] }, order: ranked })).toThrow(`its rows are ordered by an index keyed by "indexCreateTest/amount", which the system writes`); expect(() => untypedSystem({ name: "bad", kernel: "cpu", components: { write: [amount] }, order: Index.create({ key: [name], sorted: true }) })).toThrow(`ordered by an index keyed by "indexCreateTest/name", a string, which kernels don't read yet`); }); + it("throw for a render system's order by what isn't a sorted index", () => { + const untypedRender = (order: object): unknown => Reflect.apply(System.render, undefined, [{ name: "bad", order, shader: { accessors: { amount } } }, () => {}]); + expect(() => untypedRender({ name: "fake" })).toThrow(`System "bad" orders its rows by a value Index.create didn't return`); + expect(() => untypedRender(Index.create({ name: "indexCreateTest/hashedForRender", key: [amount] }))).toThrow(`orders its rows by index "indexCreateTest/hashedForRender", which isn't sorted`); + }); }); diff --git a/packages/ecs/src/interface/system/render.ts b/packages/ecs/src/interface/system/render.ts index f031d58a..c0e82eb7 100644 --- a/packages/ecs/src/interface/system/render.ts +++ b/packages/ecs/src/interface/system/render.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Component } from '../component/component.js'; +import type { Index } from '../indexes/index-type.js'; import { takeIdOfDeclaration } from '../registry/ids.js'; import { nameOf } from '../registry/items.js'; import { claimSystem } from '../registry/program.js'; @@ -22,7 +23,10 @@ import type { OneThread, ThreadedBy, ThreadsOf } from './threads.js'; */ export type Shader = { readonly group?: number; readonly accessors: { readonly [name: string]: StructComponent } }; -/** One group of matching rows, `0` to `count`. `bind` sets the world's bind group for it on a pass. */ +/** + * One group of matching rows, `0` to `count`: an archetype's, or with `order` every matching row in its order. `bind` + * sets the world's bind group for it on a pass. + */ export type RenderTable = { readonly count: number; readonly bind: (pass: GPURenderPassEncoder | GPUComputePassEncoder) => void; @@ -44,6 +48,11 @@ type RenderDeclaration = { readonly components?: { readonly has?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; /** It runs only while `gpuDevice` is set, as if `is` listed it. */ readonly resources?: { readonly read?: readonly Resource.Key[]; readonly is?: readonly Condition[]; readonly not?: readonly Condition[] }; + /** + * Draws every matching row as one table, in this sorted index's order: key order, then entity order. Its shaders' + * accessors then take a row's position in that order. + */ + readonly order?: Index<{ readonly sorted: true; readonly key: readonly Component.Key[] }>; readonly shader: Shader; readonly before?: readonly System[]; readonly after?: readonly System[]; @@ -56,7 +65,12 @@ type Rendered = D & { readonly components: { readon /** * Declares a render system: it runs once per run on `gpuDevice`'s thread, in schedule order, and records GPU work over * the GPU copy of the components its accessors name, which the world uploads first as needed. It runs only while the - * world has a `gpuDevice`. Throws as `create` does, and for an accessor its shader can't read on the GPU. + * world has a `gpuDevice`. Its body's `tables` hold one table per matching archetype, each in row order; with `order`, + * exactly one table of every matching row, across archetypes, in the index's order, so instance `i` is the i-th: a + * shader's accessors take that position as their `row`, with the same names and signatures. Throws as `create` does, + * for an `order` that isn't a sorted index, and for an accessor its shader can't read on the GPU. A run with `order` + * fails, as a failure in its report, when a matching archetype holds more rows than 32 bits name beside its slot: + * 2^31 with up to two matching archetypes, 2^30 with up to four, and so on. */ export const render = ( declaration: D & OneThread | ThreadOfResource, ThreadedBy>, diff --git a/packages/ecs/src/interface/system/system.type-test.ts b/packages/ecs/src/interface/system/system.type-test.ts index e000bb66..2e00dbc8 100644 --- a/packages/ecs/src/interface/system/system.type-test.ts +++ b/packages/ecs/src/interface/system/system.type-test.ts @@ -386,6 +386,33 @@ System.render({ name: "test/drawShared", shader: { accessors: { place: position drawAll(gpu, pass); pass.end(); }); +// `order` draws every matching archetype as one table, in a sorted index's order. +declare const byPosition: Index<{ readonly key: readonly [typeof position]; readonly sorted: true }>; +declare const hashedByPosition: Index<{ readonly key: readonly [typeof position] }>; +declare const someIndex: Index; +System.render({ name: "test/drawOrdered", components: { has: [player] }, order: byPosition, shader: { accessors: { place: position } } }, gpu => { + const pass = gpu.encoder.beginRenderPass({ colorAttachments: [] }); + drawAll(gpu, pass); + pass.end(); +}); +System.render({ + name: "test/drawOrdered", + // @ts-expect-error a render system is ordered by a sorted index + order: hashedByPosition, + shader: { accessors: { place: position } }, +}, () => {}); +System.render({ + name: "test/drawOrdered", + // @ts-expect-error an index whose declaration isn't known isn't known to be sorted + order: someIndex, + shader: { accessors: { place: position } }, +}, () => {}); +System.render({ + name: "test/drawOrdered", + // @ts-expect-error an order is an index + order: [position], + shader: { accessors: { place: position } }, +}, () => {}); // Names are optional: a declaration may leave it out, and a system that declares nothing is just its body. export const unnamed = System.create({ components: { write: [position] }, resources: { read: [time] } }, (row, { resources }) => { diff --git a/packages/ecs/src/perftest/render-order.ts b/packages/ecs/src/perftest/render-order.ts new file mode 100644 index 00000000..204777cd --- /dev/null +++ b/packages/ecs/src/perftest/render-order.ts @@ -0,0 +1,91 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Component } from "../interface/component/component.js"; +import { Entity } from "../interface/entity/entity.js"; +import { Index } from "../interface/indexes/index-type.js"; +import { gpuDevice } from "../interface/resources/gpu-device.js"; +import { workers } from "../interface/resources/workers.js"; +import { System } from "../interface/system/system.js"; +import { createWorld } from "../implementation/world/create.js"; +import type { Benchmark } from "./benchmark.js"; + +// A 2D editor's paint order: shapes and images, two archetypes, interleaved in depth, drawn as one table in a sorted +// index's order. + +const paintDepth = Symbol("renderOrder/paintDepth"); +const shape = Symbol("renderOrder/shape"); +const image = Symbol("renderOrder/image"); +declare module "@adobe/ecs" { + export interface Components { + [paintDepth]: { type: number }; + [shape]: { type: true }; + [image]: { type: true }; + } +} +Component.register(paintDepth, { type: { type: "number", precision: 1 } }); +Component.register(shape, { type: { const: true } }); +Component.register(image, { type: { const: true } }); + +const byDepth = Index.create({ name: "renderOrder/byDepth", key: [paintDepth], sorted: true }); + +const items = 50_000; +let spawned: Entity[] = []; +const spawn = System.create({ name: "renderOrder/spawn" }, ({ commands }) => { + spawned = []; + for (let i = 0; i < items; i++) { + spawned.push(i % 2 === 0 ? commands.spawn([paintDepth, shape])({ [paintDepth]: i, [shape]: true }) : commands.spawn([paintDepth, image])({ [paintDepth]: i, [image]: true })); + } +}); +/** Moves one item to a new depth, as reordering it does. */ +const restack = System.create({ + name: "renderOrder/restack", + components: { write: [paintDepth] }, + input: { type: "object", properties: { item: Entity.schema, depth: { type: "number" } } }, + target: "item", +}, (row, { input }) => { + row[paintDepth] = input.depth; +}); +/** Draws nothing: its run uploads what it reads and its order. */ +const draw = System.render({ name: "renderOrder/draw", order: byDepth, shader: { accessors: { paintDepth } }, after: [restack] }, () => {}); +const frame = System.create({ name: "renderOrder/frame", systems: [restack, draw] }); + +/** Timed per run by `timed`, which waits for the GPU. */ +export const renderOrderBenchmarks = (device: GPUDevice, timed: (run: () => Promise) => Promise): readonly Benchmark[] => [{ + name: "gpu ordered render unchanged 50K", + operation: "A render system with an order over 50K shapes and images, two archetypes interleaved in depth, run with nothing changed: it uploads nothing and writes neither its order nor its record.", + per: "runs", + n: 1, + typical: [1, 1], + best: { bytes: 64, basis: "A run reads at least one cache line of its system's state." }, + run: async () => { + const world = createWorld({ [workers]: 0, [gpuDevice]: device }); + try { + await world.run(spawn); + await world.run(draw); + return await timed(() => world.run(draw)); + } + finally { + await world.dispose(); + } + }, +}, { + name: "gpu ordered render one key 50K", + operation: "A target system moving one of 50K shapes and images to another depth, then a render system ordered by depth, which refreshes the index, lists the 50K in order and writes them, 200 KB.", + per: "runs", + n: 1, + typical: [1, 1], + best: { bytes: 400_000, basis: "The order's 50K 4-byte entries are written on the CPU and again by the upload.", on: "gpu" }, + run: async () => { + const world = createWorld({ [workers]: 0, [gpuDevice]: device }); + try { + await world.run(spawn); + const item = spawned[items / 2]; + let depth = 0.5; + await world.run(frame, { item, depth }); + // Between two others each time, so its place in the order moves. + return await timed(() => world.run(frame, { item, depth: depth = (depth + 1009) % items })); + } + finally { + await world.dispose(); + } + }, +}]; diff --git a/packages/ecs/src/perftest/suite.perf-browser-test.ts b/packages/ecs/src/perftest/suite.perf-browser-test.ts index a3ff5451..104f74af 100644 --- a/packages/ecs/src/perftest/suite.perf-browser-test.ts +++ b/packages/ecs/src/perftest/suite.perf-browser-test.ts @@ -18,6 +18,7 @@ import { indexBenchmarks } from "./indexes.js"; import { structReplace, valueBenchmarks } from "./values.js"; import { observedGpuBenchmarks } from "./observed-gpu.js"; import { uploadBenchmarks } from "./uploads.js"; +import { renderOrderBenchmarks } from "./render-order.js"; /** Every worker's copy bandwidth together, through browser workers running the calibration script. */ const browserThreadsBandwidth = async (): Promise => { @@ -103,6 +104,7 @@ const benchmarks = (device: GPUDevice): readonly Benchmark[] => [ ...transferBenchmarks, ...observedGpuBenchmarks(device, run => gpuFastest(device, run)), ...uploadBenchmarks(device, run => gpuFastest(device, run)), + ...renderOrderBenchmarks(device, run => gpuFastest(device, run)), ...[[100_000, 10, "100K chains of 10"], [1_000, 1_000, "1K chains of 1,000"]].map(([roots, depth, chains]): Benchmark => ({ name: `up depth ${depth} gpu`, operation: upOperation(String(chains)), From 826c45ad2588104587fa61d58a619b7002d5b152 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 22 Oct 2026 00:13:16 -0700 Subject: [PATCH 2/2] ecs: version 0.2.0, with order on System.render Co-Authored-By: Claude Opus 5.5 --- packages/ecs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ecs/package.json b/packages/ecs/package.json index 8c605e03..79a5ad67 100644 --- a/packages/ecs/package.json +++ b/packages/ecs/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/ecs", - "version": "0.1.0", + "version": "0.2.0", "description": "Adobe ECS", "license": "MIT", "type": "module",