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
8 changes: 8 additions & 0 deletions ecs/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ A feature's or package's root folder holds only its public exports, one file per
tests. Its implementation lives in folders below. Nothing outside a feature imports below its root, and within a
package only its entry file re-exports.

## The guide

Every library has a section of the guide. Its chapters are in its own `src/guide`, listed by a `README.md` there, each
its prose, `NN-name.md`, a program, `NN-name.ts`, and what it prints, `NN-name.out`; its package.json's `guide` field
names, in `files`, what the guide's in-browser compiler reads of it; it's a dependency of `ecs-guide`; and its
`src/guide/guide.test.ts` passes its chapters to `ecs-guide-checks`, with what each teaches and the changes that take
each point away, which its output must catch. A chapter's output is the same with a GPU or without one.

## Never use top-level await

Do not use `await` at module top level in any package's sources. Defer async work to a lazy-init pattern: store the
Expand Down
1 change: 1 addition & 0 deletions ecs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ A high-performance, multithreaded Entity Component System for web-based applicat
- **[`ecs-guide`](packages/ecs-guide)**: the guide, each example edited in place, checked, compiled and run in the
browser as it's typed.
- **[`ecs-sample-browser`](packages/ecs-sample-browser)**: the sample browser, a tab for each sample.
- **[`ecs-guide-checks`](packages/ecs-guide-checks)**: the checks every package's guide chapters pass.

## Online

Expand Down
9 changes: 8 additions & 1 deletion ecs/packages/ecs-gpu/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@adobe/ecs-gpu",
"version": "0.0.1",
"version": "0.0.2",
"description": "WebGPU devices, surfaces, render targets, views and frame passes for @adobe/ecs",
"license": "MIT",
"type": "module",
Expand Down Expand Up @@ -31,6 +31,7 @@
"@types/node": "^25.6.0",
"@vitest/browser-playwright": "^5.0.5",
"@webgpu/types": "^0.1.61",
"ecs-guide-checks": "workspace:*",
"playwright": "^1.64.1",
"typescript": "^5.8.3",
"vite": "^8.3.4",
Expand All @@ -41,5 +42,11 @@
"import": "./dist/index.js",
"types": "./dist/types/index.d.ts"
}
},
"guide": {
"files": [
"src/**/*.ts",
"dist/**/*.js"
]
}
}
13 changes: 13 additions & 0 deletions ecs/packages/ecs-gpu/src/guide/01-targets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Render targets and views

A *render target* is an entity with [`Target.options`](01-targets.ts#L8), what its textures are, and `Target.size`, in
pixels. The options are its color format, its samples per pixel, its depth format and the color it's cleared to each
frame; `Target.Options.default` has one sample and a depth buffer. With a device, `Target.states` holds its textures.

A *view* is an entity drawing into part of a target: `View.target` names the target, `View.viewport` its rectangle in
it, as fractions of its size, and `View.projection` the matrix taking its scene to clip space.
[Two views](01-targets.ts#L9-L10) split the screen in halves.

- **Owned:** `View.target` is an owner reference, so [despawning the target](01-targets.ts#L27) takes its views with it.
- **Shaders:** `View.wgsl(1)` gives the WGSL declaring a view's uniform at bind group 1, as `view.projection` and
`view.size`, which `View.layout(device)` describes.
5 changes: 5 additions & 0 deletions ecs/packages/ecs-gpu/src/guide/01-targets.out
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
target [ 800, 600 ] samples 4
view of screen at [ 0, 0, 0.5, 1 ]
view of screen at [ 0.5, 0, 0.5, 1 ]
@group(1) @binding(0) var<uniform> view: View;
views left after the screen went: 0
40 changes: 40 additions & 0 deletions ecs/packages/ecs-gpu/src/guide/01-targets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// © 2026 Adobe. MIT License. See /LICENSE for details.
import { Entity, Mat4x4, System, World } from "@adobe/ecs";
import { Target, View } from "../index.js";

let screen = Entity.none;

const spawnScreen = System.create(({ commands }) => {
screen = commands.spawn([Target.options, Target.size])({ [Target.options]: { ...Target.Options.default, sampleCount: 4 }, [Target.size]: [800, 600] });
commands.spawn([View.target, View.viewport, View.projection])({ [View.target]: screen, [View.viewport]: [0, 0, 0.5, 1], [View.projection]: Mat4x4.identity });
commands.spawn([View.target, View.viewport, View.projection])({ [View.target]: screen, [View.viewport]: [0.5, 0, 0.5, 1], [View.projection]: Mat4x4.identity });
});

const listTargets = System.create({ readonly: true, components: { with: [Target.options, Target.size] } }, row => {
console.log("target", row[Target.size], "samples", row[Target.options].sampleCount);
});

const listViews = System.create({ readonly: true, components: { with: [View.target, View.viewport] } }, row => {
console.log("view of", row[View.target] === screen ? "screen" : "another", "at", row[View.viewport]);
});

let views = 0;
const countViews = System.create({ readonly: true, components: { with: [View.target] } }, () => {
views += 1;
});

const removeScreen = System.create(({ commands }) => {
commands.despawn(screen);
});

export const main = async (): Promise<void> => {
await using world = World.create();
await world.run(spawnScreen);
await world.run(listTargets);
await world.run(listViews);
console.log(View.wgsl(1).split("\n")[1]);
// Its views go with it: each view's target owns it.
await world.run(removeScreen);
await world.run(countViews);
console.log("views left after the screen went:", views);
};
15 changes: 15 additions & 0 deletions ecs/packages/ecs-gpu/src/guide/02-frames.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Devices, surfaces and frames

`Device.request()` gives a WebGPU device, or null where there's none, and a world drawing with it takes it as
[`gpuDevice`](02-frames.ts#L20-L21). `Device.keep(world)` sets it and asks again each time it's lost.

- **Surfaces:** `Surface.attach` makes a render target presenting to a canvas, an `OffscreenCanvas`, so the world may
run on a worker. [Here](02-frames.ts#L26-L27) it takes the page's canvas; `Surface.observe` keeps the target the
canvas's size on the page, in device pixels.
- **Frames:** `Frame.systems` brings targets, surfaces and views up to date with the device and clears each target, then
presents each surface. A frame runs them with its draws between, which declare
[`after: [Frame.update], before: [Frame.present]`](02-frames.ts#L6-L8).
- **Passes:** `View.begin(encoder, view)` begins a pass drawing into a view, limited to its rectangle.

Without a device, as in node, the world runs the same systems and draws nothing: a frame still updates
`View.states`, to no views.
2 changes: 2 additions & 0 deletions ecs/packages/ecs-gpu/src/guide/02-frames.out
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
views updated before a frame false
views updated after a frame true
33 changes: 33 additions & 0 deletions ecs/packages/ecs-gpu/src/guide/02-frames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// © 2026 Adobe. MIT License. See /LICENSE for details.
import { gpuDevice, Mat4x4, System, World } from "@adobe/ecs";
import { Device, Frame, Surface, Target, View } from "../index.js";

// Draws each view: a pass loading what its target holds, limited to the view's rectangle.
const draw = System.render({ shader: { accessors: {} }, after: [Frame.update], before: [Frame.present] }, ({ encoder }, { resources }) => {
for (const view of resources[View.states]?.values() ?? []) View.begin(encoder, view).end();
});

const frame = System.create({ systems: [Frame.systems, draw] });

const spawnView = System.create({ components: { with: [Target.size] } }, (row, { commands }) => {
commands.spawn([View.target, View.viewport, View.projection])({ [View.target]: row.entity, [View.viewport]: [0, 0, 1, 1], [View.projection]: Mat4x4.identity });
});

const updated = System.create({ readonly: true }, ({ resources }) => resources[View.states] !== null);

export const main = async (): Promise<void> => {
// Null where there's no WebGPU, as in node: the world then draws nothing, and runs the same systems.
const device = await Device.request();
const world = World.create({ [gpuDevice]: device });
// The guide's Output tab has an element #output; elsewhere, as in node, there's none.
const output = globalThis.document?.querySelector<HTMLElement>("#output") ?? null;
const canvas = output?.appendChild(document.createElement("canvas")) ?? null;
if (canvas !== null) {
const surface = await world.run(Surface.attach, { canvas: canvas.transferControlToOffscreen(), width: canvas.clientWidth, height: canvas.clientHeight, target: { ...Target.Options.default, clear: [0.1, 0.3, 0.6, 1] } });
Surface.observe(canvas, world, surface);
await world.run(spawnView);
}
console.log("views updated before a frame", await world.run(updated));
for (let frames = 0; frames < 3; frames++) await world.run(frame);
console.log("views updated after a frame", await world.run(updated));
};
7 changes: 7 additions & 0 deletions ecs/packages/ecs-gpu/src/guide/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Guide

How to draw with `@adobe/ecs-gpu`, in chapters, each its prose, `NN-name.md`, and a program, `NN-name.ts`, whose output
is `NN-name.out`. The [interactive guide](../../../ecs-guide) shows them after the ECS's own chapters.

1. [Render targets and views](01-targets.md)
2. [Devices, surfaces and frames](02-frames.md)
19 changes: 19 additions & 0 deletions ecs/packages/ecs-gpu/src/guide/guide.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// © 2026 Adobe. MIT License. See /LICENSE for details.
import { checkChapters } from "ecs-guide-checks";

/** What each chapter teaches, which its prose names as code and its program does. */
const teaches: { readonly [chapter: number]: readonly string[] } = {
1: ["Target.options", "Target.size", "Target.Options.default", "View.target", "View.viewport", "View.projection", "View.wgsl(1)"],
2: ["Device.request()", "gpuDevice", "Surface.attach", "Surface.observe", "Frame.systems", "after: [Frame.update], before: [Frame.present]", "View.begin(encoder, view)", "View.states"],
};

/**
* Changes to each chapter's program, each taking away a point it teaches, which its output catches: each its code as
* written, once in the program, what takes its place, and the failure it then prints, where it's one.
*/
const mutations: { readonly [chapter: number]: readonly (readonly [string, string, string?])[] } = {
1: [["[View.viewport]: [0.5, 0, 0.5, 1]", "[View.viewport]: [0, 0, 0.5, 1]"], ["sampleCount: 4 ", "sampleCount: 1 "], ["commands.despawn(screen);", ""], ["View.wgsl(1)", "View.wgsl(2)"]],
2: [["for (let frames = 0; frames < 3; frames++) await world.run(frame);", ""]],
};

checkChapters({ folder: new URL(".", import.meta.url), teaches, mutations });
4 changes: 3 additions & 1 deletion ecs/packages/ecs-gpu/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export default defineConfig({
minify: false,
sourcemap: true,
lib: { entry: fileURLToPath(new URL("src/index.ts", import.meta.url)), formats: ["es"], fileName: "index" },
rolldownOptions: { external: ["@adobe/ecs", "@adobe/ecs/compiler"] },
// Its keys stay top-level `const` declarations, which an application's build requires of a key it reads, such as
// a trait's in its own declarations: library mode would make them `var`.
rolldownOptions: { external: ["@adobe/ecs", "@adobe/ecs/compiler"], output: { topLevelVar: false } },
},
});
40 changes: 27 additions & 13 deletions ecs/packages/ecs-gpu/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,34 @@ const isolation: Plugin = {
*/
const gpu = process.env["ECS_GPU"] === "1" || process.env["CI"] === undefined;

// Every test runs in Chromium. A test's timeout only guards against a hang, which no test asserts on.
/** A test's timeout only guards against a hang, which no test asserts on. */
const timeouts = { testTimeout: 30000, hookTimeout: 30000 };

// The guide's chapters run in node, as the guide checks every package's; every other test runs in Chromium.
export default defineConfig({
plugins: [Compiler.plugin(), isolation],
test: {
include: ["src/**/*.test.ts"],
testTimeout: 30000,
hookTimeout: 30000,
fileParallelism: false,
provide: { gpu },
browser: {
enabled: true,
provider: playwright({ launchOptions: { channel: "chromium", args: gpu ? ["--enable-unsafe-webgpu"] : ["--disable-gpu"] } }),
headless: true,
instances: [{ browser: "chromium" }],
},
projects: [
{
plugins: [Compiler.plugin()],
test: { ...timeouts, name: "guide", include: ["src/guide/**/*.test.ts"], environment: "node" },
},
{
plugins: [Compiler.plugin(), isolation],
test: {
...timeouts,
name: "browser",
include: ["src/**/*.test.ts"],
exclude: ["src/guide/**", "**/node_modules/**"],
fileParallelism: false,
provide: { gpu },
browser: {
enabled: true,
provider: playwright({ launchOptions: { channel: "chromium", args: gpu ? ["--enable-unsafe-webgpu"] : ["--disable-gpu"] } }),
headless: true,
instances: [{ browser: "chromium" }],
},
},
},
],
},
});
15 changes: 15 additions & 0 deletions ecs/packages/ecs-guide-checks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# ecs-guide-checks

The checks every package's guide chapters pass, for its `src/guide/guide.test.ts`:

```ts
checkChapters({ folder: new URL(".", import.meta.url), teaches, mutations });
```

- **Files:** each program has its prose and its `.out`, and the guide's index lists every chapter, numbered as its
files are and titled as its prose is.
- **Output:** each program prints what its `.out` says, its package's version left out of ids.
- **Teaching:** each chapter's prose shows, as code, what `teaches` says it teaches, and its program uses it.
- **Mutations:** each of `mutations` takes a point away from a program, and its output must change, to the failure
given where there is one.
- **Links:** the prose links only to chapters there are, and to whole lines of code of its own program.
21 changes: 21 additions & 0 deletions ecs/packages/ecs-guide-checks/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "ecs-guide-checks",
"version": "0.0.0",
"description": "The checks every package's guide chapters pass: their programs print what they say, and each point they teach shows in that output",
"license": "MIT",
"type": "module",
"private": true,
"exports": {
".": "./src/check-chapters.ts"
},
"scripts": {
"typecheck": "tsc"
},
"dependencies": {
"typescript": "^5.8.3",
"vitest": "^5.0.5"
},
"devDependencies": {
"@types/node": "^25.6.0"
}
}
Loading
Loading