@expofp/renderer 3.37.0 → 3.38.0

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,62 @@
1
+ import { type SourceReport } from '../pack-sources';
2
+ import { type WorkerAtlas, type WorkerSource } from './atlas-worker';
3
+ /** Options for {@link packInWorker}. */
4
+ export interface PackInWorkerOptions {
5
+ /** Abandons the pack. The worker is told to drop it, and a reply that still arrives is thrown away. */
6
+ signal?: AbortSignal;
7
+ /**
8
+ * Makes the pooled worker, on the first pack that needs it. Defaults to the bundled atlas worker,
9
+ * and exists so a spec can drive the protocol against a fake one without a browser. It is ignored
10
+ * once a worker is pooled.
11
+ */
12
+ createWorker?: () => Worker;
13
+ }
14
+ /**
15
+ * Packs a set of sources in a web worker.
16
+ *
17
+ * **One worker serves every pack, and no pack ever stops it.** The atlases come back as
18
+ * `ImageBitmap`s that `OffscreenCanvas.transferToImageBitmap()` produced, and Chrome backs those
19
+ * with GPU memory the worker owns. `Images` re-reads an atlas's pixels on every texture-budget
20
+ * move, and disposing a texture forces a fresh upload, so the producing worker has to outlive every
21
+ * bitmap it made, not only the first upload of it. Terminating the worker used to run before this
22
+ * function resolved, which destroyed those pixels and left booth logos blank on real GPUs.
23
+ *
24
+ * The one `terminate()` in this file is in `retire`, and it only ever stops a worker that broke
25
+ * before it handed out an atlas. A worker that has produced pixels is dropped from the pool and
26
+ * left running. The worker script is inlined into the bundle, so starting one resolves no URL at
27
+ * runtime, which matters because the SDK is loaded from origins it does not control.
28
+ *
29
+ * Each pack carries an `id`, because a shared worker can hold two layers' packs at once and a reply
30
+ * has to find its own caller. An abandoned pack is settled here and its `id` forgotten, so a reply
31
+ * that still arrives is discarded — and its atlases are closed, or each superseded pack would leak
32
+ * a layer's worth of GPU memory.
33
+ *
34
+ * The sources go over in batches so the main thread is never blocked for long by one structured
35
+ * clone, and so the worker can decode the early batches while the later ones are still being sent.
36
+ * A closing message tells it every source has arrived.
37
+ * @param sources - The unique sources to pack, in the caller's order; placements address them by
38
+ * their index in this array.
39
+ * @param maxDimension - Largest atlas edge in pixels.
40
+ * @param options - The abort signal, and the worker factory a spec overrides.
41
+ * @returns The packed atlases and one report per source.
42
+ */
43
+ export declare function packInWorker(sources: readonly WorkerSource[], maxDimension: number, options?: PackInWorkerOptions): Promise<{
44
+ atlases: WorkerAtlas[];
45
+ reports: SourceReport[];
46
+ }>;
47
+ /**
48
+ * Forgets the pooled worker without stopping it, and abandons every pack waiting on it.
49
+ *
50
+ * Only a spec calls this. Each spec drives its own fake worker, and the pool would otherwise hand
51
+ * the first spec's fake to every spec after it.
52
+ */
53
+ export declare function resetAtlasWorkerPool(): void;
54
+ /**
55
+ * How many sources one `postMessage` carries.
56
+ *
57
+ * Sending every source in one message left 18 dropped frames at a thousand logos on a 4x-throttled
58
+ * CPU; sending them in batches of 25 left one. Chunking is what removes the last stall, so this is
59
+ * the number that matters most in this file — tune it against the benchmark, not by taste.
60
+ */
61
+ export declare const SOURCE_CHUNK_SIZE = 25;
62
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1,249 @@
1
+ /* eslint-disable-next-line import/default -- Vite answers this specifier with a module it
2
+ generates, whose default export is a `Worker` subclass. The ESLint resolver drops the query and
3
+ reads the worker entry instead, which exports no default of its own. `vite/client` declares the
4
+ real shape, so tsc sees it. */
5
+ import AtlasWorker from './atlas-worker?worker&inline';
6
+ /**
7
+ * Packs a set of sources in a web worker.
8
+ *
9
+ * **One worker serves every pack, and no pack ever stops it.** The atlases come back as
10
+ * `ImageBitmap`s that `OffscreenCanvas.transferToImageBitmap()` produced, and Chrome backs those
11
+ * with GPU memory the worker owns. `Images` re-reads an atlas's pixels on every texture-budget
12
+ * move, and disposing a texture forces a fresh upload, so the producing worker has to outlive every
13
+ * bitmap it made, not only the first upload of it. Terminating the worker used to run before this
14
+ * function resolved, which destroyed those pixels and left booth logos blank on real GPUs.
15
+ *
16
+ * The one `terminate()` in this file is in `retire`, and it only ever stops a worker that broke
17
+ * before it handed out an atlas. A worker that has produced pixels is dropped from the pool and
18
+ * left running. The worker script is inlined into the bundle, so starting one resolves no URL at
19
+ * runtime, which matters because the SDK is loaded from origins it does not control.
20
+ *
21
+ * Each pack carries an `id`, because a shared worker can hold two layers' packs at once and a reply
22
+ * has to find its own caller. An abandoned pack is settled here and its `id` forgotten, so a reply
23
+ * that still arrives is discarded — and its atlases are closed, or each superseded pack would leak
24
+ * a layer's worth of GPU memory.
25
+ *
26
+ * The sources go over in batches so the main thread is never blocked for long by one structured
27
+ * clone, and so the worker can decode the early batches while the later ones are still being sent.
28
+ * A closing message tells it every source has arrived.
29
+ * @param sources - The unique sources to pack, in the caller's order; placements address them by
30
+ * their index in this array.
31
+ * @param maxDimension - Largest atlas edge in pixels.
32
+ * @param options - The abort signal, and the worker factory a spec overrides.
33
+ * @returns The packed atlases and one report per source.
34
+ */
35
+ export function packInWorker(sources, maxDimension, options = {}) {
36
+ const { signal, createWorker = createAtlasWorker } = options;
37
+ return new Promise((resolve, reject) => {
38
+ // Read the signal before anything is sent. A pack that is already abandoned must cost the
39
+ // worker nothing, so it posts no message at all.
40
+ if (signal?.aborted) {
41
+ reject(abandoned());
42
+ return;
43
+ }
44
+ let worker;
45
+ try {
46
+ worker = pooledWorker(createWorker);
47
+ }
48
+ catch (error) {
49
+ // A host page whose `script-src` forbids `blob:` and `data:` makes `new Worker()` throw. The
50
+ // caller falls back to an inline pack.
51
+ reject(asError(error));
52
+ return;
53
+ }
54
+ const id = ++lastRequestId;
55
+ // Every exit runs through here. Dropping this pack's entry is what stops delivery, because the
56
+ // worker is shared and goes on running.
57
+ const done = (settle) => {
58
+ signal?.removeEventListener('abort', onAbort);
59
+ pending.delete(id);
60
+ settle();
61
+ };
62
+ // A plain Error, not `signal.reason`, which Chrome 66 and Safari 15 do not have. Nothing
63
+ // branches on the error's type: every caller reads `signal.aborted` instead.
64
+ const onAbort = () => {
65
+ done(() => {
66
+ cancel(worker, id);
67
+ reject(abandoned());
68
+ });
69
+ };
70
+ pending.set(id, (message) => {
71
+ done(() => {
72
+ if (message.type === 'failed')
73
+ reject(new Error(message.message));
74
+ else
75
+ resolve({ atlases: message.atlases, reports: message.reports });
76
+ });
77
+ });
78
+ signal?.addEventListener('abort', onAbort);
79
+ try {
80
+ for (let start = 0; start < sources.length; start += SOURCE_CHUNK_SIZE) {
81
+ post(worker, {
82
+ type: 'sources',
83
+ id,
84
+ sources: sources.slice(start, start + SOURCE_CHUNK_SIZE),
85
+ });
86
+ }
87
+ post(worker, { type: 'pack', id, maxDimension });
88
+ }
89
+ catch (error) {
90
+ // `postMessage` throws when the platform refuses to copy a source. The worker keeps the
91
+ // batches that did arrive until it is told otherwise, and the closing message never comes.
92
+ done(() => {
93
+ cancel(worker, id);
94
+ reject(asError(error));
95
+ });
96
+ }
97
+ });
98
+ }
99
+ /**
100
+ * Forgets the pooled worker without stopping it, and abandons every pack waiting on it.
101
+ *
102
+ * Only a spec calls this. Each spec drives its own fake worker, and the pool would otherwise hand
103
+ * the first spec's fake to every spec after it.
104
+ */
105
+ export function resetAtlasWorkerPool() {
106
+ pool = null;
107
+ pending.clear();
108
+ }
109
+ /**
110
+ * Hands back the one worker every pack runs on, starting it on the first call.
111
+ *
112
+ * The reply handler lives on the worker and not on a pack, because the worker outlives every pack
113
+ * it serves and a reply for a pack nobody wants still has pixels to free.
114
+ * @param createWorker - Makes the worker, if there is not one yet.
115
+ * @returns The pooled worker.
116
+ */
117
+ function pooledWorker(createWorker) {
118
+ if (pool)
119
+ return pool.worker;
120
+ // Not stored until it exists: a factory that throws must be retried by the next pack, not cached.
121
+ const started = { worker: createWorker(), produced: false };
122
+ started.worker.onmessage = (event) => {
123
+ const message = event.data;
124
+ const deliver = pending.get(message.id);
125
+ if (deliver) {
126
+ // These pixels are about to be mounted, so from here on this worker must not be stopped.
127
+ if (message.type === 'packed' && message.atlases.length > 0)
128
+ started.produced = true;
129
+ deliver(message);
130
+ return;
131
+ }
132
+ // Nobody is waiting: this pack was abandoned after the worker had already replied. An
133
+ // `ImageBitmap` holds its pixels outside the JavaScript heap and nothing else will ever close
134
+ // these, so every superseded pack would leak a layer's worth of GPU memory.
135
+ if (message.type === 'packed')
136
+ for (const atlas of message.atlases)
137
+ atlas.bitmap.close();
138
+ };
139
+ started.worker.onerror = (event) => {
140
+ retire(started);
141
+ failAll(event.message || 'atlas-worker: failed to start');
142
+ };
143
+ started.worker.onmessageerror = () => {
144
+ // This never fires on the primary target. `Worker`'s `messageerror` is `false` for Chrome,
145
+ // Chrome Android and Android WebView, and Safari only added it in 16.4, which is above the
146
+ // `safari15`/`ios15` floor (checked against `@mdn/browser-compat-data@6.1.5`, as installed
147
+ // here). Assigning it is free and Firefox has had it since 57, so it is kept for the browsers
148
+ // that do support it. It is not a guard the build targets can rely on.
149
+ //
150
+ // A reply that does not deserialize is lost, and it carries no id, so there is no way to tell
151
+ // whose it was. Without this the layer waits for a reply that never comes and stays blank.
152
+ retire(started);
153
+ failAll('atlas-worker: a reply could not be read');
154
+ };
155
+ pool = started;
156
+ return started.worker;
157
+ }
158
+ /**
159
+ * Drops a broken worker, so the next pack starts a fresh one.
160
+ *
161
+ * A worker that has handed out an atlas is dropped and left running. Stopping it would destroy the
162
+ * GPU memory behind every bitmap it made, which is the failure this whole file is shaped around. A
163
+ * worker that produced nothing is stopped, because nothing depends on it.
164
+ * @param broken - The pooled worker to drop.
165
+ */
166
+ function retire(broken) {
167
+ if (pool === broken)
168
+ pool = null;
169
+ if (!broken.produced)
170
+ broken.worker.terminate();
171
+ }
172
+ /**
173
+ * Abandons every pack waiting on the worker that just broke.
174
+ * @param message - What to reject each of them with.
175
+ */
176
+ function failAll(message) {
177
+ for (const [id, deliver] of [...pending])
178
+ deliver({ type: 'failed', id, message });
179
+ }
180
+ /**
181
+ * Tells the worker to drop a pack nobody wants any more.
182
+ *
183
+ * Termination used to do this. Now that the worker outlives its packs, a superseded pack would
184
+ * otherwise run to the end and hold the worker off the next one.
185
+ * @param worker - The pooled worker.
186
+ * @param id - The pack to drop.
187
+ */
188
+ function cancel(worker, id) {
189
+ try {
190
+ post(worker, { type: 'cancel', id });
191
+ }
192
+ catch {
193
+ // A worker that will not take this message is already unusable, and the pack it would have
194
+ // dropped is dropped either way.
195
+ }
196
+ }
197
+ /**
198
+ * Sends one request to the worker.
199
+ *
200
+ * **Nothing is ever transferred.** A `Blob` is a handle to an immutable, reference-counted byte
201
+ * store, so a structured clone copies the handle and not the bytes. Being cheap to clone is the
202
+ * property that matters here; being transferable is not, and transferring a Blob is not a thing the
203
+ * platform offers. A `Blob` is also the only source that reaches this function, so no object a def
204
+ * owns is ever detached by a pack.
205
+ *
206
+ * The typed parameter is why this function exists: `postMessage` takes `any`, so a message built at
207
+ * the call site would go unchecked.
208
+ * @param worker - The worker to send to.
209
+ * @param request - The message.
210
+ */
211
+ function post(worker, request) {
212
+ worker.postMessage(request);
213
+ }
214
+ /**
215
+ * Makes the bundled atlas worker.
216
+ * @returns A fresh worker.
217
+ */
218
+ function createAtlasWorker() {
219
+ return new AtlasWorker();
220
+ }
221
+ /**
222
+ * The error every abandoned pack rejects with.
223
+ * @returns The error.
224
+ */
225
+ function abandoned() {
226
+ return new Error('atlas-worker: the pack was abandoned');
227
+ }
228
+ /**
229
+ * Turns whatever was thrown into an `Error`.
230
+ * @param error - What was thrown.
231
+ * @returns An error.
232
+ */
233
+ function asError(error) {
234
+ return error instanceof Error ? error : new Error(String(error));
235
+ }
236
+ /**
237
+ * How many sources one `postMessage` carries.
238
+ *
239
+ * Sending every source in one message left 18 dropped frames at a thousand logos on a 4x-throttled
240
+ * CPU; sending them in batches of 25 left one. Chunking is what removes the last stall, so this is
241
+ * the number that matters most in this file — tune it against the benchmark, not by taste.
242
+ */
243
+ export const SOURCE_CHUNK_SIZE = 25;
244
+ /** Every pack that has been sent and is waiting for its reply, by request id. */
245
+ const pending = new Map();
246
+ /** The worker every pack runs on, once the first pack has started it. */
247
+ let pool = null;
248
+ /** The last id handed to a pack. Ids only ever climb, so no reply can find the wrong caller. */
249
+ let lastRequestId = 0;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The worker's public face: the main thread's half only.
3
+ *
4
+ * The worker entry is deliberately absent. It imports `packSources` and carries the
5
+ * `?worker&inline` specifier, and nothing outside this directory should pull either in by
6
+ * reaching past the barrel. Types are the exception, because they are erased at emit.
7
+ */
8
+ export { type WorkerAtlas, type WorkerSource } from './atlas-worker';
9
+ export { packInWorker, type PackInWorkerOptions, SOURCE_CHUNK_SIZE } from './client';
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ export { packInWorker, SOURCE_CHUNK_SIZE } from './client';
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { OneFactor } from 'three';
2
3
  import { useDimPatch } from './effects/color-dim';
3
4
  import { sharedParams } from './shared';
4
5
  /**
@@ -25,5 +26,14 @@ export function TextureMaterial(props) {
25
26
  vMapUv = uv * ${uvOffset}.zw + ${uvOffset}.xy;
26
27
  `.trim());
27
28
  }
28
- return _jsx("meshBasicMaterial", { ...sharedParams, map: map, onBeforeCompile: onBeforeCompile });
29
+ // An atlas texture is premultiplied (see `makeAtlasTexture`'s `premultiplyAlpha` comment), so
30
+ // this material needs the premultiplied blend, not `sharedParams`'s straight-alpha one.
31
+ // `blendSrc: OneFactor` is the only change: the source term becomes `1 * (c * a)`, which already
32
+ // equals the straight blend's own `c * a` source term, so the composited color comes out the
33
+ // same either way. `blendDst` stays `OneMinusSrcAlphaFactor` from `sharedParams`, since that half
34
+ // does not depend on how the source is stored.
35
+ //
36
+ // The override lives here, not in `sharedParams`, because `ColorMaterial` and `LineMaterial`
37
+ // share that object and their colors are never premultiplied.
38
+ return (_jsx("meshBasicMaterial", { ...sharedParams, blendSrc: OneFactor, map: map, onBeforeCompile: onBeforeCompile }));
29
39
  }
@@ -166,14 +166,25 @@ const DIM_FUNCTION = 'vec4 dimColor(';
166
166
  /**
167
167
  * Dimming's file-scope declarations.
168
168
  *
169
- * Dim is a desaturation toward gray, not a darkening — which is why it cannot be baked into a
169
+ * Dim is a desaturation toward gray, not a darkening. That is why it cannot be baked into a
170
170
  * per-instance tint: a texel sampled per fragment has no tint to bake into.
171
171
  *
172
- * `dimColor` takes straight (non-premultiplied) `gl_FragColor` and returns it the same way. The
173
- * imperative renderer divided rgb by alpha here and multiplied it back at the end; the round-trip
174
- * cancels — `mix(c, dot(w, c) / k, t)` is linear and homogeneous in `c`, so `f(c / a) * a == f(c)`
175
- * for every alpha — and it was the one thing that could produce a NaN, at `a == 0`, which SDF text
176
- * edges generate in quantity. Dropping it changes no pixel.
172
+ * `dimColor` works on `gl_FragColor` whichever way it is stored, and hands it back stored the same
173
+ * way. It only ever scales `col.rgb`: `mix(c, dot(w, c) / k, t)` is linear and homogeneous in `c`,
174
+ * so scaling the input by any constant scales the output by that same constant. That homogeneity
175
+ * is also why a premultiplied, `SRGBColorSpace`-tagged atlas texture dims to the same composited
176
+ * pixels as the straight-alpha texture this renderer used before it. Measuring old against new
177
+ * leaves a small residual: at most about 8 of 255 on one channel, across every alpha and dim
178
+ * amount tested.
179
+ *
180
+ * `stories/objects/layer/Dim.stories.tsx` does not cover any of this. Its image layer draws
181
+ * `markerCanvas(tint)`, whose whole surface is opaque, so no pixel in that story ever carries a
182
+ * partial alpha for `dimColor` to distort.
183
+ *
184
+ * The imperative renderer divided rgb by alpha here and multiplied it back at the end. The round
185
+ * trip cancels for the same reason: `f(c / a) * a == f(c)` for every alpha. Dividing by alpha was
186
+ * also the one step that could produce a NaN, at `a == 0`, which SDF text edges generate in
187
+ * quantity. Dropping it changes no pixel.
177
188
  */
178
189
  const DIM_DEFS = /* glsl */ `
179
190
  uniform float ${LAYER_DIM};
@@ -2,8 +2,22 @@ import { type Box, type LineLike, type Rect, type Shape } from '@expofp/geometry
2
2
  import { type LayoutHandlers, type PointerHandlers } from './events';
3
3
  /** A color as a css string or a packed numeric value. */
4
4
  export type ColorInput = string | number;
5
- /** Any image-like source that can be uploaded as a texture. */
6
- export type ImageSource = HTMLImageElement | HTMLCanvasElement | ImageBitmap | OffscreenCanvas;
5
+ /**
6
+ * Any image-like source that the renderer can upload as a texture.
7
+ *
8
+ * A `Blob` is how anything fetched over the network arrives. It is a handle to an immutable byte
9
+ * store that the browser reference-counts. `postMessage` clones the handle, not the bytes. That is
10
+ * what lets atlas packing move to a worker. The renderer decodes the `Blob` itself, off the main
11
+ * thread wherever the environment allows it.
12
+ *
13
+ * `HTMLImageElement` is deliberately absent. Decoding one costs the main thread more than the
14
+ * packing it feeds: at a thousand logos on a throttled CPU, it stalled the page for two seconds.
15
+ * Leaving the member in the union would keep that slow path reachable. Pass a `Blob` instead.
16
+ *
17
+ * There is no `string` member either. The caller keeps all networking, so the renderer never
18
+ * learns about URLs, retries, or CORS.
19
+ */
20
+ export type ImageSource = HTMLCanvasElement | ImageBitmap | OffscreenCanvas | Blob;
7
21
  /** Scene definition */
8
22
  export interface SceneDef {
9
23
  /** Root layer of the scene tree */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expofp/renderer",
3
- "version": "3.37.0",
3
+ "version": "3.38.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/expofp/efp-app.git",
@@ -42,7 +42,7 @@
42
42
  "troika-three-text": "^0.52.5",
43
43
  "tslib": "^2.3.0",
44
44
  "webgl-memory": "^1.1.3",
45
- "@expofp/geometry": "3.37.0"
45
+ "@expofp/geometry": "3.38.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@react-three/test-renderer": "^9.1.0",