@adobe/ecs-gpu 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { type Entity, Finalizer, System, type Vec2, type World } from "@adobe/ecs";
3
+ import * as Target from "../target/public.js";
4
+
5
+ /** How a surface presents: how its alpha composites with the page, its color space, and its tone mapping. */
6
+ export type Options = {
7
+ readonly alphaMode: GPUCanvasAlphaMode;
8
+ readonly colorSpace: PredefinedColorSpace;
9
+ readonly toneMapping: GPUCanvasToneMappingMode;
10
+ };
11
+
12
+ export const Options = {
13
+ default: { alphaMode: "opaque", colorSpace: "srgb", toneMapping: "standard" },
14
+ } as const;
15
+
16
+ /** A surface's canvas, configured for the world's device in its target's format. */
17
+ export type State = {
18
+ readonly entity: Entity;
19
+ readonly canvas: OffscreenCanvas;
20
+ readonly context: GPUCanvasContext;
21
+ readonly device: GPUDevice;
22
+ readonly format: GPUTextureFormat;
23
+ readonly options: Options;
24
+ };
25
+
26
+ /**
27
+ * A render target with these presents to a canvas: the canvas, on the world's thread, and how it presents. The
28
+ * canvas's size follows the target's.
29
+ */
30
+ export const canvas = Symbol("Surface.canvas");
31
+ export const options = Symbol("Surface.options");
32
+ /** Each surface's configured canvas, by its entity: null until surfaces are first updated. */
33
+ export const states = Symbol("Surface.states");
34
+
35
+ declare module "@adobe/ecs" {
36
+ interface Components {
37
+ [canvas]: { type: OffscreenCanvas; transfer: true };
38
+ [options]: { type: Options };
39
+ }
40
+ interface Resources {
41
+ [states]: { type: ReadonlyMap<Entity, State> | null };
42
+ }
43
+ }
44
+
45
+ /**
46
+ * A surface presenting to `canvas`, moved to the world's thread, of `width` by `height` pixels: a canvas's
47
+ * `transferControlToOffscreen()`, or an `OffscreenCanvas` of its own. Resolves to its entity.
48
+ */
49
+ export const attach = System.create({}, ({ commands }, input: {
50
+ readonly canvas: OffscreenCanvas;
51
+ readonly width: number;
52
+ readonly height: number;
53
+ readonly target?: Target.Options;
54
+ readonly surface?: Options;
55
+ }) => commands.spawn([Target.options, Target.size, canvas, options])({
56
+ [Target.options]: input.target ?? Target.Options.default,
57
+ [Target.size]: [Math.max(1, Math.floor(input.width)), Math.max(1, Math.floor(input.height))],
58
+ [canvas]: input.canvas,
59
+ [options]: input.surface ?? Options.default,
60
+ }));
61
+
62
+ const sameOptions = (a: Options, b: Options): boolean => a.alphaMode === b.alphaMode && a.colorSpace === b.colorSpace && a.toneMapping === b.toneMapping;
63
+
64
+ /** A surface as an index lists it: its entity, canvas, options and its target's size. */
65
+ export type Listed = { readonly entity: Entity; readonly [canvas]: OffscreenCanvas; readonly [options]: Options; readonly [Target.size]: Vec2.U32 };
66
+
67
+ /**
68
+ * Each surface's canvas sized to its target and configured for `device` in its target's format, again where either
69
+ * changed; with no device, or no target state, unconfigured. Gives `previous` itself when nothing changed.
70
+ */
71
+ export const updated = (device: GPUDevice | null, targets: ReadonlyMap<Entity, Target.State>, previous: ReadonlyMap<Entity, State> | null, surfaces: { readonly length: number; at(index: number): Listed }): ReadonlyMap<Entity, State> => {
72
+ const held = previous ?? new Map<Entity, State>();
73
+ let next: Map<Entity, State> | null = null;
74
+ for (let i = 0; i < surfaces.length; i++) {
75
+ const surface = surfaces.at(i);
76
+ const presented = surface[canvas];
77
+ const [width, height] = surface[Target.size];
78
+ if (presented.width !== width) presented.width = width;
79
+ if (presented.height !== height) presented.height = height;
80
+ const target = targets.get(surface.entity);
81
+ const old = held.get(surface.entity);
82
+ if (device === null || target === undefined) {
83
+ if (old === undefined) continue;
84
+ old.context.unconfigure();
85
+ next ??= new Map(held);
86
+ next.delete(surface.entity);
87
+ continue;
88
+ }
89
+ const declared = surface[options];
90
+ if (old !== undefined && old.device === device && old.format === target.format && old.canvas === presented && sameOptions(old.options, declared)) continue;
91
+ const context = presented.getContext("webgpu");
92
+ if (context === null) continue;
93
+ context.configure({
94
+ device, format: target.format, usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_DST,
95
+ alphaMode: declared.alphaMode, colorSpace: declared.colorSpace, toneMapping: { mode: declared.toneMapping },
96
+ });
97
+ next ??= new Map(held);
98
+ next.set(surface.entity, { entity: surface.entity, canvas: presented, context, device, format: target.format, options: declared });
99
+ }
100
+ return next ?? held;
101
+ };
102
+
103
+ /** Unconfigures a surface's canvas when it stops being one. */
104
+ export const release = Finalizer.create({ components: { with: [canvas] } }, (surface, { resources }) => {
105
+ const current = resources[states];
106
+ const state = current?.get(surface.entity);
107
+ if (current === null || state === undefined) return;
108
+ state.context.unconfigure();
109
+ const next = new Map(current);
110
+ next.delete(surface.entity);
111
+ resources[states] = next;
112
+ });
113
+
114
+ /** A canvas's size in device pixels, from what a `ResizeObserver` saw of it. */
115
+ const pixelsOf = (entry: ResizeObserverEntry): readonly [number, number] => {
116
+ const [device] = entry.devicePixelContentBoxSize ?? [];
117
+ if (device !== undefined) return [device.inlineSize, device.blockSize];
118
+ const [box] = entry.contentBoxSize;
119
+ const scale = globalThis.devicePixelRatio ?? 1;
120
+ return box === undefined ? [entry.contentRect.width * scale, entry.contentRect.height * scale] : [box.inlineSize * scale, box.blockSize * scale];
121
+ };
122
+
123
+ /**
124
+ * Keeps a surface's size the size of `element` on the page, in device pixels, on the thread the page is on: the world
125
+ * may be here or on a worker that joined it. Returns a function that stops.
126
+ */
127
+ export const observe = (element: Element, world: Pick<World, "run">, surface: Entity): (() => void) => {
128
+ const observer = new ResizeObserver(entries => {
129
+ const entry = entries.at(-1);
130
+ if (entry === undefined) return;
131
+ const [width, height] = pixelsOf(entry);
132
+ world.run(Target.resize, { target: surface, width, height }).catch(reportError);
133
+ });
134
+ try {
135
+ observer.observe(element, { box: "device-pixel-content-box" });
136
+ } catch {
137
+ observer.observe(element);
138
+ }
139
+ return () => observer.disconnect();
140
+ };
@@ -0,0 +1,2 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ export { Options, options, release, resize, size, states, type State } from "./target.js";
@@ -0,0 +1,133 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { type Entity, Finalizer, System, type Vec2, type Vec4 } from "@adobe/ecs";
3
+
4
+ /**
5
+ * What a render target draws into: its color format, null for the platform's preferred canvas format; its samples per
6
+ * pixel, more than one drawing multisampled and resolving into its color; its depth format, null for none; and the
7
+ * color it's cleared to each frame, null to keep what it holds.
8
+ */
9
+ export type Options = {
10
+ readonly format: GPUTextureFormat | null;
11
+ readonly sampleCount: 1 | 4;
12
+ readonly depthFormat: GPUTextureFormat | null;
13
+ readonly clear: Vec4 | null;
14
+ };
15
+
16
+ export const Options = {
17
+ default: { format: null, sampleCount: 1, depthFormat: "depth24plus", clear: [0, 0, 0, 1] },
18
+ } as const;
19
+
20
+ /**
21
+ * A target's textures on the world's device. `color` holds what's drawn, single-sampled, for reading and presenting;
22
+ * with more than one sample, passes draw into `multisampled` and resolve into `color`. `size` is in pixels.
23
+ */
24
+ export type State = {
25
+ readonly entity: Entity;
26
+ readonly device: GPUDevice;
27
+ readonly options: Options;
28
+ readonly format: GPUTextureFormat;
29
+ readonly size: Vec2.U32;
30
+ readonly color: GPUTexture;
31
+ readonly multisampled: GPUTexture | null;
32
+ readonly depth: GPUTexture | null;
33
+ };
34
+
35
+ /** An entity with these is a render target: what its textures are, and their size in pixels. */
36
+ export const options = Symbol("Target.options");
37
+ export const size = Symbol("Target.size");
38
+ /** Each render target's textures on the world's device, by its entity: null until targets are first updated. */
39
+ export const states = Symbol("Target.states");
40
+
41
+ declare module "@adobe/ecs" {
42
+ interface Components {
43
+ [options]: { type: Options };
44
+ [size]: { type: Vec2.U32 };
45
+ }
46
+ interface Resources {
47
+ [states]: { type: ReadonlyMap<Entity, State> | null };
48
+ }
49
+ }
50
+
51
+ const sameOptions = (a: Options, b: Options): boolean =>
52
+ a.format === b.format && a.sampleCount === b.sampleCount && a.depthFormat === b.depthFormat
53
+ && (a.clear === b.clear || (a.clear !== null && b.clear !== null && a.clear.every((value, index) => value === b.clear?.[index])));
54
+
55
+ const destroyTextures = (state: State): void => {
56
+ state.color.destroy();
57
+ state.multisampled?.destroy();
58
+ state.depth?.destroy();
59
+ };
60
+
61
+ const created = (device: GPUDevice, entity: Entity, declared: Options, [width, height]: Vec2.U32): State => {
62
+ const format = declared.format ?? navigator.gpu.getPreferredCanvasFormat();
63
+ const extent = [width, height];
64
+ const color = device.createTexture({
65
+ label: `target ${entity} color`, size: extent, format,
66
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC | GPUTextureUsage.COPY_DST,
67
+ });
68
+ const multisampled = declared.sampleCount === 1 ? null : device.createTexture({
69
+ label: `target ${entity} multisampled`, size: extent, format, sampleCount: declared.sampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT,
70
+ });
71
+ const depth = declared.depthFormat === null ? null : device.createTexture({
72
+ label: `target ${entity} depth`, size: extent, format: declared.depthFormat, sampleCount: declared.sampleCount,
73
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
74
+ });
75
+ return { entity, device, options: declared, format, size: [width, height], color, multisampled, depth };
76
+ };
77
+
78
+ /** A render target as an index lists it: its entity, options and size. */
79
+ export type Listed = { readonly entity: Entity; readonly [options]: Options; readonly [size]: Vec2.U32 };
80
+
81
+ /**
82
+ * Each render target's textures on `device`, made again where its options, its size or the device changed, destroying
83
+ * those they replace; with no device, none. Gives `previous` itself when nothing changed.
84
+ */
85
+ export const updated = (device: GPUDevice | null, previous: ReadonlyMap<Entity, State> | null, targets: { readonly length: number; at(index: number): Listed }): ReadonlyMap<Entity, State> => {
86
+ const held = previous ?? new Map<Entity, State>();
87
+ let next: Map<Entity, State> | null = null;
88
+ for (let i = 0; i < targets.length; i++) {
89
+ const target = targets.at(i);
90
+ const old = held.get(target.entity);
91
+ const [width, height] = target[size];
92
+ if (device !== null && old !== undefined && old.device === device && old.size[0] === width && old.size[1] === height && sameOptions(old.options, target[options])) continue;
93
+ if (device === null && old === undefined) continue;
94
+ next ??= new Map(held);
95
+ if (old !== undefined) destroyTextures(old);
96
+ if (device === null) next.delete(target.entity);
97
+ else next.set(target.entity, created(device, target.entity, target[options], target[size]));
98
+ }
99
+ return next ?? held;
100
+ };
101
+
102
+ /** Records clearing each target that declares a clear color: its color to that color, and its depth to 1. */
103
+ export const recordClears = (encoder: GPUCommandEncoder, targets: ReadonlyMap<Entity, State>): void => {
104
+ for (const target of targets.values()) {
105
+ const color = target.options.clear;
106
+ if (color === null) continue;
107
+ const [r, g, b, a] = color;
108
+ encoder.beginRenderPass({
109
+ label: `clear target ${target.entity}`,
110
+ colorAttachments: [{
111
+ view: (target.multisampled ?? target.color).createView(), resolveTarget: target.multisampled === null ? undefined : target.color.createView(),
112
+ clearValue: { r, g, b, a }, loadOp: "clear", storeOp: "store",
113
+ }],
114
+ depthStencilAttachment: target.depth === null ? undefined : { view: target.depth.createView(), depthClearValue: 1, depthLoadOp: "clear", depthStoreOp: "store" },
115
+ }).end();
116
+ }
117
+ };
118
+
119
+ /** Destroys a render target's textures when it stops being one. */
120
+ export const release = Finalizer.create({ components: { with: [options, size] } }, (target, { resources }) => {
121
+ const current = resources[states];
122
+ const state = current?.get(target.entity);
123
+ if (current === null || state === undefined) return;
124
+ destroyTextures(state);
125
+ const next = new Map(current);
126
+ next.delete(target.entity);
127
+ resources[states] = next;
128
+ });
129
+
130
+ /** Sets a render target's size in pixels, at least one by one. */
131
+ export const resize = System.create({ components: { with: [size] }, target: "target" }, (row, _context, input: { readonly target: Entity; readonly width: number; readonly height: number }) => {
132
+ row[size] = [Math.max(1, Math.floor(input.width)), Math.max(1, Math.floor(input.height))];
133
+ });
@@ -0,0 +1,2 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ export { begin, layout, projection, release, states, target, viewport, wgsl, type State } from "./view.js";
@@ -0,0 +1,125 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { type Entity, Finalizer, type Mat4x4, type Vec4 } from "@adobe/ecs";
3
+ import { perDevice } from "../cache/cache.js";
4
+ import * as Target from "../target/public.js";
5
+
6
+ /**
7
+ * A view's place on its target's device: the target, its rectangle in the target in pixels (`x`, `y`, `width`,
8
+ * `height`), and the uniform buffer and bind group holding its projection and size, for `wgsl`'s declarations.
9
+ */
10
+ export type State = {
11
+ readonly entity: Entity;
12
+ readonly target: Target.State;
13
+ readonly rectangle: readonly [number, number, number, number];
14
+ readonly projection: Mat4x4;
15
+ readonly buffer: GPUBuffer;
16
+ readonly bindGroup: GPUBindGroup;
17
+ };
18
+
19
+ /**
20
+ * An entity with these is a view: the render target it draws into, which owns it; its rectangle in that target as
21
+ * fractions of its size (`x`, `y`, `width`, `height`); and the matrix taking its scene to clip space.
22
+ */
23
+ export const target = Symbol("View.target");
24
+ export const viewport = Symbol("View.viewport");
25
+ export const projection = Symbol("View.projection");
26
+ /** Each view's state on the world's device, by its entity: null until views are first updated. */
27
+ export const states = Symbol("View.states");
28
+
29
+ declare module "@adobe/ecs" {
30
+ interface Components {
31
+ [target]: { type: Entity; reference: "owner" };
32
+ [viewport]: { type: Vec4; default: [0, 0, 1, 1] };
33
+ [projection]: { type: Mat4x4 };
34
+ }
35
+ interface Resources {
36
+ [states]: { type: ReadonlyMap<Entity, State> | null };
37
+ }
38
+ }
39
+
40
+ /** The uniform each view binds: its projection, then its size in pixels. */
41
+ const uniformBytes = 80;
42
+
43
+ const layouts = perDevice((device: GPUDevice, _key: null) => device.createBindGroupLayout({
44
+ label: "view",
45
+ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT | GPUShaderStage.COMPUTE, buffer: { type: "uniform" } }],
46
+ }), () => "");
47
+
48
+ /** The bind group layout of a view's uniform, one per device: a pipeline's layout takes it at the group `wgsl` names. */
49
+ export const layout = (device: GPUDevice): GPUBindGroupLayout => layouts(device, null);
50
+
51
+ /** The WGSL declarations of a view's uniform at bind group `group`: `view.projection` and `view.size`, in pixels. */
52
+ export const wgsl = (group: number): string =>
53
+ `struct View { projection: mat4x4f, size: vec2f }\n@group(${group}) @binding(0) var<uniform> view: View;\n`;
54
+
55
+ const rectangleOf = ([x, y, width, height]: Vec4, [targetWidth, targetHeight]: readonly [number, number]): readonly [number, number, number, number] => {
56
+ const left = Math.min(targetWidth - 1, Math.max(0, Math.round(x * targetWidth)));
57
+ const top = Math.min(targetHeight - 1, Math.max(0, Math.round(y * targetHeight)));
58
+ return [left, top, Math.max(1, Math.min(targetWidth - left, Math.round(width * targetWidth))), Math.max(1, Math.min(targetHeight - top, Math.round(height * targetHeight)))];
59
+ };
60
+
61
+ const sameRectangle = (a: readonly number[], b: readonly number[]): boolean => a.every((value, index) => value === b[index]);
62
+ const sameMatrix = (a: Mat4x4, b: Mat4x4): boolean => a.every((value, index) => value === b[index]);
63
+
64
+ /** A view as an index lists it: its entity, target, viewport and projection. */
65
+ export type Listed = { readonly entity: Entity; readonly [target]: Entity; readonly [viewport]: Vec4; readonly [projection]: Mat4x4 };
66
+
67
+ /**
68
+ * Each view placed in its target, its uniform written where its projection or rectangle changed; a view whose target
69
+ * has no textures, as without a device, has no state. Gives `previous` itself when nothing changed.
70
+ */
71
+ export const updated = (device: GPUDevice | null, targets: ReadonlyMap<Entity, Target.State>, previous: ReadonlyMap<Entity, State> | null, views: { readonly length: number; at(index: number): Listed }): ReadonlyMap<Entity, State> => {
72
+ const held = previous ?? new Map<Entity, State>();
73
+ let next: Map<Entity, State> | null = null;
74
+ for (let i = 0; i < views.length; i++) {
75
+ const view = views.at(i);
76
+ const old = held.get(view.entity);
77
+ const drawn = device === null ? undefined : targets.get(view[target]);
78
+ if (device === null || drawn === undefined) {
79
+ if (old === undefined) continue;
80
+ old.buffer.destroy();
81
+ next ??= new Map(held);
82
+ next.delete(view.entity);
83
+ continue;
84
+ }
85
+ const rectangle = rectangleOf(view[viewport], drawn.size);
86
+ const matrix = view[projection];
87
+ if (old !== undefined && old.target === drawn && sameRectangle(old.rectangle, rectangle) && sameMatrix(old.projection, matrix)) continue;
88
+ const kept = old !== undefined && old.target.device === device;
89
+ if (old !== undefined && !kept) old.buffer.destroy();
90
+ const buffer = kept ? old.buffer : device.createBuffer({ label: `view ${view.entity}`, size: uniformBytes, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
91
+ const bindGroup = kept ? old.bindGroup : device.createBindGroup({ layout: layout(device), entries: [{ binding: 0, resource: { buffer } }] });
92
+ device.queue.writeBuffer(buffer, 0, new Float32Array([...matrix, rectangle[2], rectangle[3], 0, 0]));
93
+ next ??= new Map(held);
94
+ next.set(view.entity, { entity: view.entity, target: drawn, rectangle, projection: matrix, buffer, bindGroup });
95
+ }
96
+ return next ?? held;
97
+ };
98
+
99
+ /** Destroys a view's uniform buffer when it stops being one. */
100
+ export const release = Finalizer.create({ components: { with: [target, viewport, projection] } }, (view, { resources }) => {
101
+ const current = resources[states];
102
+ const state = current?.get(view.entity);
103
+ if (current === null || state === undefined) return;
104
+ state.buffer.destroy();
105
+ const next = new Map(current);
106
+ next.delete(view.entity);
107
+ resources[states] = next;
108
+ });
109
+
110
+ /**
111
+ * Begins a render pass drawing into a view: its target's textures, loading what they hold, limited to its rectangle,
112
+ * multisampled passes resolving into the target's color.
113
+ */
114
+ export const begin = (encoder: GPUCommandEncoder, view: State, label?: string): GPURenderPassEncoder => {
115
+ const { color, multisampled, depth } = view.target;
116
+ const pass = encoder.beginRenderPass({
117
+ label,
118
+ colorAttachments: [{ view: (multisampled ?? color).createView(), resolveTarget: multisampled === null ? undefined : color.createView(), loadOp: "load", storeOp: "store" }],
119
+ depthStencilAttachment: depth === null ? undefined : { view: depth.createView(), depthLoadOp: "load", depthStoreOp: "store" },
120
+ });
121
+ const [x, y, width, height] = view.rectangle;
122
+ pass.setViewport(x, y, width, height, 0, 1);
123
+ pass.setScissorRect(x, y, width, height);
124
+ return pass;
125
+ };