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
8 changes: 7 additions & 1 deletion packages/ecs/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ Humans own the interface and agents own the implementation: agents don't add to
It's a project of its own: nothing in it imports from outside it. Each type exists once, there.
- **`src/implementation`** holds `createWorld` and everything behind it, the kernel compiler included. It imports
types and the registry's read side from `src/interface`.
- **`src/samples`** holds one folder per sample, its page included; `pnpm sample` serves them.
- **`src/samples`** holds one folder per sample, its page included. `pnpm dev` (or `pnpm sample`, without
the type-check watch) serves them, with a sample browser at the root that has a tab for each.
- `src/index.ts` exports `src/interface`, and `World` as a value too, whose `create` and `join` are the
implementation's, so `import { World } from "@adobe/ecs"` gives consumers the type, `World.create` and `World.join`,
the only entry points. Samples and the guide use them; tests and benchmarks may take `createWorld` from
Expand All @@ -44,6 +45,11 @@ when a measured win conflicts with a style or design preference, take the win an
row as low as it can be, and allocate nothing on those paths: reuse arrays, buffers and objects, share read-only
empties, and avoid closures, spreads and awaits there. Measure changes with `pnpm perf` against the recorded baseline.

## Pull requests

Each PR costs a review, a merge and a publish, so keep one open PR at a time. Add new work and fixes to it as more
commits; never open stacked or parallel PRs. Keep it rebased on main, with this package's version one above main's.

## Memory

Decisions about working on this package live in `memory.md`; add new ones there.
Expand Down
2 changes: 1 addition & 1 deletion packages/ecs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<f32>`), 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<f32>`), 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.
Expand Down
9 changes: 3 additions & 6 deletions packages/ecs/followups.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,9 @@ 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.
- **Descending sorted indexes:** an ordered render reads its index front to back, so a consumer painting back to front
maps `instance_index` to `ecs_count() - 1u - instance_index`, which costs nothing. Possible: a `descending` option on
sorted indexes. Not needed until a consumer's profile or readability says so.
- **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.
Expand Down
4 changes: 2 additions & 2 deletions packages/ecs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@adobe/ecs",
"version": "0.1.0",
"version": "0.1.1",
"description": "Adobe ECS",
"license": "MIT",
"type": "module",
Expand Down Expand Up @@ -30,7 +30,7 @@
],
"scripts": {
"build": "cp ../../LICENSE . && tsc -b",
"dev": "tsc -b -w --preserveWatchOutput",
"dev": "tsc -b -w --preserveWatchOutput & vite --config vite.sample.config.ts",
"typecheck": "tsc -b",
"test": "pnpm exec playwright install chromium && vitest --run",
"test:perf": "pnpm exec playwright install chromium && vitest --run --config vitest.perf.config.ts",
Expand Down
4 changes: 2 additions & 2 deletions packages/ecs/src/implementation/compiler/identify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { parseAst } from 'vite';
import { idOfKey, takeIdOfDeclaration } from '../../interface/registry/ids.js';
import { estreeOf } from './estree.js';
import { identify as identifyModule, moduleIdOf } from './identify.js';
import { attachKernelPath } from './paths.js';
import { ownCopy } from './paths.js';
import { decodeMappings } from './source-map.js';

const parse = (code: string) => estreeOf(parseAst(code));
Expand Down Expand Up @@ -104,7 +104,7 @@ describe("identify, for systems, rules and finalizers", () => {
expect(typeof declaration === "object" && declaration !== null ? [Reflect.get(declaration, "name"), takeIdOfDeclaration(declaration), takeIdOfDeclaration(declaration)] : declaration).toEqual(["move", "src/keys.ts#move", undefined]);
});
test("identifies a kernel's call the kernel transform wrapped", async () => {
const result = await identify(`import { attachKernel as attachKernel$ } from ${JSON.stringify(attachKernelPath)};\nimport { System } from "@adobe/ecs";\nconst grow = attachKernel$(System.create({ name: "grow", kernel: "cpu" }, () => {}), {});`);
const result = await identify(`import { attachKernel as attachKernel$ } from ${JSON.stringify(ownCopy.attachKernel)};\nimport { System } from "@adobe/ecs";\nconst grow = attachKernel$(System.create({ name: "grow", kernel: "cpu" }, () => {}), {});`);
expect(declared(result?.code)).toEqual([[`{ name: "grow", kernel: "cpu" }`, "src/keys.ts#grow"]]);
});
test("follows the namespace to the module that exports it, under any local name", async () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/ecs/src/implementation/compiler/identify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { posix } from 'node:path';
import { idsName } from '../../interface/registry/ids.js';
import { children, isFunction } from './estree.js';
import { moduleScope } from './module-scope.js';
import { attachKernelPath } from './paths.js';
import { isAttachKernelModule } from './paths.js';
import { insertInto, type Insertion, type SourceMap } from './source-map.js';

export type Parse = (code: string) => Program;
Expand Down Expand Up @@ -76,7 +76,7 @@ const localsOf = async (program: Program, resolve: ResolveNamespace): Promise<{
for (const specifier of statement.specifiers) {
if (specifier.type !== "ImportSpecifier" || specifier.imported.type !== "Identifier") continue;
const imported = specifier.imported.name;
if (statement.source.value === attachKernelPath && imported === "attachKernel") attach.add(specifier.local.name);
if (imported === "attachKernel" && isAttachKernelModule(statement.source.value)) attach.add(specifier.local.name);
else if (isNamespace(imported) && await resolve(statement.source.value, imported)) namespaces.set(specifier.local.name, imported);
}
}
Expand Down
Loading
Loading