@adobe/ecs-gpu 0.0.0-stage → 0.0.2
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.
- package/LICENSE +21 -0
- package/README.md +110 -2
- package/dist/index.js +688 -0
- package/dist/index.js.map +1 -0
- package/dist/types/cache/cache.d.ts +6 -0
- package/dist/types/cache/public.d.ts +1 -0
- package/dist/types/device/device.d.ts +24 -0
- package/dist/types/device/public.d.ts +1 -0
- package/dist/types/frame/frame.d.ts +14 -0
- package/dist/types/frame/public.d.ts +1 -0
- package/dist/types/guide/01-targets.d.ts +1 -0
- package/dist/types/guide/02-frames.d.ts +1 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/surface/public.d.ts +1 -0
- package/dist/types/surface/surface.d.ts +81 -0
- package/dist/types/target/public.d.ts +1 -0
- package/dist/types/target/target.d.ts +78 -0
- package/dist/types/view/public.d.ts +1 -0
- package/dist/types/view/view.d.ts +69 -0
- package/package.json +49 -3
- package/src/cache/cache.ts +23 -0
- package/src/cache/public.ts +2 -0
- package/src/device/device.ts +46 -0
- package/src/device/public.ts +2 -0
- package/src/frame/frame.ts +53 -0
- package/src/frame/public.ts +2 -0
- package/src/guide/01-targets.md +13 -0
- package/src/guide/01-targets.out +5 -0
- package/src/guide/01-targets.ts +40 -0
- package/src/guide/02-frames.md +15 -0
- package/src/guide/02-frames.out +2 -0
- package/src/guide/02-frames.ts +33 -0
- package/src/guide/README.md +7 -0
- package/src/index.ts +11 -0
- package/src/surface/public.ts +2 -0
- package/src/surface/surface.ts +140 -0
- package/src/target/public.ts +2 -0
- package/src/target/target.ts +133 -0
- package/src/view/public.ts +2 -0
- package/src/view/view.ts +125 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
import { Entity, Mat4x4, System, World } from "@adobe/ecs";
|
|
3
|
+
import { Target, View } from "../index.js";
|
|
4
|
+
|
|
5
|
+
let screen = Entity.none;
|
|
6
|
+
|
|
7
|
+
const spawnScreen = System.create(({ commands }) => {
|
|
8
|
+
screen = commands.spawn([Target.options, Target.size])({ [Target.options]: { ...Target.Options.default, sampleCount: 4 }, [Target.size]: [800, 600] });
|
|
9
|
+
commands.spawn([View.target, View.viewport, View.projection])({ [View.target]: screen, [View.viewport]: [0, 0, 0.5, 1], [View.projection]: Mat4x4.identity });
|
|
10
|
+
commands.spawn([View.target, View.viewport, View.projection])({ [View.target]: screen, [View.viewport]: [0.5, 0, 0.5, 1], [View.projection]: Mat4x4.identity });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const listTargets = System.create({ readonly: true, components: { with: [Target.options, Target.size] } }, row => {
|
|
14
|
+
console.log("target", row[Target.size], "samples", row[Target.options].sampleCount);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const listViews = System.create({ readonly: true, components: { with: [View.target, View.viewport] } }, row => {
|
|
18
|
+
console.log("view of", row[View.target] === screen ? "screen" : "another", "at", row[View.viewport]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
let views = 0;
|
|
22
|
+
const countViews = System.create({ readonly: true, components: { with: [View.target] } }, () => {
|
|
23
|
+
views += 1;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const removeScreen = System.create(({ commands }) => {
|
|
27
|
+
commands.despawn(screen);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const main = async (): Promise<void> => {
|
|
31
|
+
await using world = World.create();
|
|
32
|
+
await world.run(spawnScreen);
|
|
33
|
+
await world.run(listTargets);
|
|
34
|
+
await world.run(listViews);
|
|
35
|
+
console.log(View.wgsl(1).split("\n")[1]);
|
|
36
|
+
// Its views go with it: each view's target owns it.
|
|
37
|
+
await world.run(removeScreen);
|
|
38
|
+
await world.run(countViews);
|
|
39
|
+
console.log("views left after the screen went:", views);
|
|
40
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Devices, surfaces and frames
|
|
2
|
+
|
|
3
|
+
`Device.request()` gives a WebGPU device, or null where there's none, and a world drawing with it takes it as
|
|
4
|
+
[`gpuDevice`](02-frames.ts#L20-L21). `Device.keep(world)` sets it and asks again each time it's lost.
|
|
5
|
+
|
|
6
|
+
- **Surfaces:** `Surface.attach` makes a render target presenting to a canvas, an `OffscreenCanvas`, so the world may
|
|
7
|
+
run on a worker. [Here](02-frames.ts#L26-L27) it takes the page's canvas; `Surface.observe` keeps the target the
|
|
8
|
+
canvas's size on the page, in device pixels.
|
|
9
|
+
- **Frames:** `Frame.systems` brings targets, surfaces and views up to date with the device and clears each target, then
|
|
10
|
+
presents each surface. A frame runs them with its draws between, which declare
|
|
11
|
+
[`after: [Frame.update], before: [Frame.present]`](02-frames.ts#L6-L8).
|
|
12
|
+
- **Passes:** `View.begin(encoder, view)` begins a pass drawing into a view, limited to its rectangle.
|
|
13
|
+
|
|
14
|
+
Without a device, as in node, the world runs the same systems and draws nothing: a frame still updates
|
|
15
|
+
`View.states`, to no views.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
import { gpuDevice, Mat4x4, System, World } from "@adobe/ecs";
|
|
3
|
+
import { Device, Frame, Surface, Target, View } from "../index.js";
|
|
4
|
+
|
|
5
|
+
// Draws each view: a pass loading what its target holds, limited to the view's rectangle.
|
|
6
|
+
const draw = System.render({ shader: { accessors: {} }, after: [Frame.update], before: [Frame.present] }, ({ encoder }, { resources }) => {
|
|
7
|
+
for (const view of resources[View.states]?.values() ?? []) View.begin(encoder, view).end();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const frame = System.create({ systems: [Frame.systems, draw] });
|
|
11
|
+
|
|
12
|
+
const spawnView = System.create({ components: { with: [Target.size] } }, (row, { commands }) => {
|
|
13
|
+
commands.spawn([View.target, View.viewport, View.projection])({ [View.target]: row.entity, [View.viewport]: [0, 0, 1, 1], [View.projection]: Mat4x4.identity });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const updated = System.create({ readonly: true }, ({ resources }) => resources[View.states] !== null);
|
|
17
|
+
|
|
18
|
+
export const main = async (): Promise<void> => {
|
|
19
|
+
// Null where there's no WebGPU, as in node: the world then draws nothing, and runs the same systems.
|
|
20
|
+
const device = await Device.request();
|
|
21
|
+
const world = World.create({ [gpuDevice]: device });
|
|
22
|
+
// The guide's Output tab has an element #output; elsewhere, as in node, there's none.
|
|
23
|
+
const output = globalThis.document?.querySelector<HTMLElement>("#output") ?? null;
|
|
24
|
+
const canvas = output?.appendChild(document.createElement("canvas")) ?? null;
|
|
25
|
+
if (canvas !== null) {
|
|
26
|
+
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] } });
|
|
27
|
+
Surface.observe(canvas, world, surface);
|
|
28
|
+
await world.run(spawnView);
|
|
29
|
+
}
|
|
30
|
+
console.log("views updated before a frame", await world.run(updated));
|
|
31
|
+
for (let frames = 0; frames < 3; frames++) await world.run(frame);
|
|
32
|
+
console.log("views updated after a frame", await world.run(updated));
|
|
33
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Guide
|
|
2
|
+
|
|
3
|
+
How to draw with `@adobe/ecs-gpu`, in chapters, each its prose, `NN-name.md`, and a program, `NN-name.ts`, whose output
|
|
4
|
+
is `NN-name.out`. The [interactive guide](../../../ecs-guide) shows them after the ECS's own chapters.
|
|
5
|
+
|
|
6
|
+
1. [Render targets and views](01-targets.md)
|
|
7
|
+
2. [Devices, surfaces and frames](02-frames.md)
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
/**
|
|
3
|
+
* WebGPU devices, surfaces, render targets, views and frame passes for `@adobe/ecs`.
|
|
4
|
+
* @module @adobe/ecs-gpu
|
|
5
|
+
*/
|
|
6
|
+
export * as Cache from "./cache/public.js";
|
|
7
|
+
export * as Device from "./device/public.js";
|
|
8
|
+
export * as Frame from "./frame/public.js";
|
|
9
|
+
export * as Surface from "./surface/public.js";
|
|
10
|
+
export * as Target from "./target/public.js";
|
|
11
|
+
export * as View from "./view/public.js";
|
|
@@ -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,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
|
+
});
|
package/src/view/view.ts
ADDED
|
@@ -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
|
+
};
|