@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.
@@ -1,315 +1,287 @@
1
- import { MaxRectsPacker } from 'maxrects-packer';
2
1
  import { createLogger } from '../../utils/logging';
2
+ import { createAtlasCanvas, isBlob, packSources, } from './pack-sources';
3
+ import { packInWorker } from './worker';
3
4
  const logger = createLogger('atlas-packer');
4
- // Need to reduce blocking time on the main thread
5
- // Naive approach of offloading to web worker is not feasible:
6
- // HTMLImageElement can't be easily transferred, and createImageBitmap() on sources takes more time
7
- // than actual atlas packing. Needs to be investigated further
8
5
  /**
9
- * Gap kept between neighboring images in an atlas, so linear filtering at one image's edge can
10
- * never reach into the next one's pixels. The packer applies it *between* rects only — atlas edges
11
- * stay flush against the pixels — which is also what lets a tile exactly `maxDimension` across
12
- * still fit a bin.
13
- */
14
- const PADDING = 1;
15
- /**
16
- * How many source pixels per square unit of `bounds` a def may carry before it is called
17
- * over-sampled. Past this the extra resolution cannot reach the screen at any sane zoom, so the
18
- * atlas space and texture memory are simply wasted — usually a full-size logo standing in for a
19
- * marker icon.
6
+ * How many source pixels per square unit of `bounds` a def can carry before code calls it
7
+ * over-sampled. Past this limit, the extra resolution cannot reach the screen at any reasonable
8
+ * zoom. The atlas space and texture memory are then wasted, usually on a full-size logo that
9
+ * stands in for a marker icon.
20
10
  */
21
11
  const MAX_AREA_RATIO = 1000;
12
+ export { isResizableSource, } from './pack-sources';
22
13
  /**
23
- * Whether a source's pixels may be downscaled to fit a memory budget. `true` for decoded images and
24
- * bitmaps, `false` for canvases — a canvas's pixels are the caller's, counted at full size but never
25
- * compressed (see the scene's `memoryLimit`).
14
+ * Packs image defs into atlas canvases and reports where each def's pixels landed.
26
15
  *
27
- * Detected by the absence of `getContext`: `HTMLCanvasElement` and `OffscreenCanvas` both have it,
28
- * `HTMLImageElement` and `ImageBitmap` do not. A property test rather than `instanceof`, because the
29
- * SDK runs in WebViews where `OffscreenCanvas`/`ImageBitmap` may be missing as globals and an
30
- * `instanceof` against an absent one throws.
31
- * @param source - The source to classify.
32
- * @returns Whether the source can be downscaled.
33
- */
34
- export function isResizableSource(source) {
35
- return !('getContext' in source);
36
- }
37
- /**
38
- * Packs image defs into atlas canvases and reports where every def's pixels landed.
16
+ * The code first dedupes the defs. Two defs that point at the same URL, or that hold the same
17
+ * object, draw once and share one region. The code then decodes every source that is not drawable
18
+ * yet, which is what makes this function asynchronous: a `Blob` holds bytes and not pixels. The
19
+ * code then hands the decoded sources to {@link packSources}, which does the pixel work. The
20
+ * placements come back addressed by source index, and this function expands them into one entry
21
+ * per def.
39
22
  *
40
- * Sources are split by kind first — resizable (image/bitmap) from fixed (canvas) — and each kind is
41
- * packed on its own, so no atlas ever mixes the two and a memory budget can scale a resizable atlas
42
- * whole without touching a canvas's pixels. Within a kind, sources are deduped — two defs pointing
43
- * at the same URL, or holding the same canvas object, are drawn once and share one region — and then
44
- * offered to the packer whole. Anything too big for an atlas comes back parked in a natural-size bin
45
- * of its own, which the packer flags; those are the only sources cut into tiles, and the tiles go
46
- * back through the same packer. Letting the packer decide is what keeps the guarantee that no atlas
47
- * exceeds `maxDimension` without measuring every image up front.
23
+ * Packing reads only the sources' pixel dimensions, never `bounds`. So a def that later moves or
24
+ * resizes keeps its region: the quad's matrix scales it, and nothing needs repacking. A change in
25
+ * membership is different. This is a batch call with no incremental path, so an added or removed
26
+ * def needs a fresh call.
48
27
  *
49
- * Packing reads only the sources' pixel dimensions, never `bounds`, so a def that later moves or
50
- * resizes keeps its region: the quad's matrix scales it, and nothing needs repacking. Membership
51
- * changes do, though — this is a batch call with no incremental path.
28
+ * A worker that fails for any reason other than an abort falls back to the inline tier once. The
29
+ * worker is an optimization, and no host page may cost a layer its images by refusing one.
52
30
  * @param defs - The images to pack.
53
- * @param maxDimension - Largest atlas edge in pixels, floored to a positive integer.
54
- * @param options - Overrides for how atlas canvases are made.
55
- * @returns One entry per atlas, in packing order — resizable atlases first, then fixed.
31
+ * @param maxDimension - The largest atlas edge, in pixels. The function floors this to a positive
32
+ * integer.
33
+ * @param options - Overrides for how atlas canvases are made, and the signal that abandons the
34
+ * pack.
35
+ * @returns One entry for each atlas, in packing order: resizable atlases first, then fixed ones.
56
36
  */
57
- export function packAtlases(defs, maxDimension, options = {}) {
37
+ export async function packAtlases(defs, maxDimension, options = {}) {
58
38
  const startedAt = performance.now();
59
- const { createCanvas = createDomCanvas } = options;
60
- const limit = Math.max(1, Math.floor(maxDimension));
61
- const resizable = [];
62
- const fixed = [];
63
- for (const def of defs)
64
- (isResizableSource(def.source) ? resizable : fixed).push(def);
65
- // One packer for both groups, `reset()` between them — the config (size, padding, options) is the
66
- // same for each, so reusing it spares an allocation per pack. It cannot be shared across pack calls
67
- // though: `limit` is baked in at construction and varies call to call.
68
- // `pot: false` because WebGL 2 samples non-power-of-two textures without restriction, so rounding
69
- // each atlas up would only waste memory. `smart` stays on (the default), shrinking every bin to
70
- // its content — hence reading the size back off the bin rather than assuming `limit`.
71
- const packer = new MaxRectsPacker(limit, limit, PADDING, { pot: false });
72
- const packedResizable = packGroup(packer, resizable, limit, createCanvas, true);
73
- const packedFixed = packGroup(packer, fixed, limit, createCanvas, false);
74
- const atlases = [...packedResizable.atlases, ...packedFixed.atlases];
39
+ const { createCanvas = createAtlasCanvas, signal } = options;
40
+ const entries = collectSources(defs);
41
+ const sources = entries.map((entry) => entry.source);
42
+ let tier = selectTier(sources);
43
+ let packed = null;
44
+ if (tier === 'worker') {
45
+ try {
46
+ packed = await packWorkerTier(entries, sources, maxDimension, signal);
47
+ }
48
+ catch (error) {
49
+ // An abort is the caller's own decision, and nothing may run after it.
50
+ if (signal?.aborted)
51
+ throw error;
52
+ // Any other failure is the worker alone failing, and the main thread can still do the work.
53
+ // The case that matters is a host page whose `script-src` forbids `blob:` and `data:`:
54
+ // `new Worker()` throws there, this SDK runs on origins it does not control, and without
55
+ // this the layer would stay blank on that site.
56
+ logger.warn('the worker could not pack, packing inline instead %O', error);
57
+ tier = 'inline';
58
+ }
59
+ }
60
+ // Only if the worker tier did not run, or ran and failed. `??=` does not evaluate its right side
61
+ // when the left side already holds the atlases.
62
+ packed ??= await packInlineTier(entries, sources, maxDimension, createCanvas, signal);
75
63
  const elapsed = (performance.now() - startedAt).toFixed(1);
76
- logger.debug('packed defs: %d (%d resizable), sources: %d, tiles: %d, atlases: %d (%sms)', packedResizable.defs + packedFixed.defs, packedResizable.defs, packedResizable.sources + packedFixed.sources, packedResizable.tiles + packedFixed.tiles, atlases.length, elapsed);
77
- return atlases;
64
+ logger.debug('packed defs: %d, sources: %d, atlases: %d, tier: %s (%sms)', packed.reduce((count, atlas) => count + atlas.images.length, 0), entries.length, packed.length, tier, elapsed);
65
+ return packed;
78
66
  }
79
67
  /**
80
- * Packs one kind of source — all resizable, or all fixed — into atlases, tagging each with the
81
- * group's kind. Same pipeline for both: dedup, offer whole, cut whatever the packer parks oversized.
82
- * @param packer - The shared packer, reset to empty before this group is added.
83
- * @param defs - The defs of this kind; may be empty, which packs to nothing.
84
- * @param limit - Largest atlas edge in pixels.
85
- * @param createCanvas - Makes each atlas canvas.
86
- * @param resizable - The kind, stamped onto every atlas produced.
87
- * @returns The atlases and their counts.
68
+ * Picks the higher tier when this environment and this set of sources both allow it.
69
+ *
70
+ * A layer packs in the worker only when **every** source is a `Blob`. One source of any other kind
71
+ * keeps the whole layer on the main thread.
72
+ *
73
+ * The test names what may cross, never what must stay. A test that only excludes the known-bad
74
+ * kinds admits every kind nobody thought of, and this code shipped that mistake once: it asked
75
+ * whether a source was not a DOM canvas, which let an `<img>` through. An `<img>` neither clones
76
+ * nor moves, so `postMessage` threw on it and the layer drew nothing.
77
+ *
78
+ * A `Blob` is also the only source the worker can take without taking it from the def that owns it.
79
+ * Its handle clones, so the def keeps its bytes. Every other kind would have to move, and a moved
80
+ * source is dead on the main thread, so the next repack would find nothing to draw. Those sources
81
+ * lose nothing by packing inline. A canvas cannot cross at all — `transferControlToOffscreen` throws
82
+ * on a canvas that already holds a 2D context, and every floorplan canvas source holds one — and
83
+ * `createImageBitmap(canvas)` costs more than a pack of that canvas on the main thread (this is
84
+ * measured, see the design spec).
85
+ * @param sources - The unique sources that this pack will draw.
86
+ * @returns The tier to run.
88
87
  */
89
- function packGroup(packer, defs, limit, createCanvas, resizable) {
90
- const sources = collectSources(defs);
91
- const { rects, skipped } = sourceRects(sources.values());
92
- // Clear whatever the previous group left in the packer, keeping its configuration.
93
- packer.reset();
94
- // `addArray` sorts by longest edge before placing, which packs far tighter than insertion order.
95
- packer.addArray(rects);
96
- // The packer tags whatever it had to park in an oversized bin, so it — not a measurement of our
97
- // own — decides what needs cutting. The tiles are ordinary rects, and pack into the bins the
98
- // first pass already opened.
99
- const oversized = rects.filter((rect) => rect.oversized);
100
- if (oversized.length > 0)
101
- packer.addArray(oversized.flatMap((rect) => cutTiles(rect, limit)));
102
- // Every oversized bin is now represented by its tiles, so it is dropped rather than returned:
103
- // handing back a canvas larger than the caller's limit is the thing this all exists to prevent.
104
- const bins = packer.bins.filter((bin) => !bin.rects.some((rect) => rect.oversized));
105
- const atlases = bins.map((bin) => ({ ...renderAtlas(bin, createCanvas), resizable }));
106
- return {
107
- atlases,
108
- defs: defs.length - skipped.defs,
109
- sources: sources.size - skipped.sources,
110
- tiles: bins.reduce((count, bin) => count + bin.rects.length, 0),
111
- };
88
+ export function selectTier(sources) {
89
+ const offthread = typeof Worker === 'function' && typeof OffscreenCanvas !== 'undefined' && sources.every(isBlob);
90
+ return offthread ? 'worker' : 'inline';
112
91
  }
113
92
  /**
114
- * The pixel size of an image source.
93
+ * Packs in a worker, off the main thread.
115
94
  *
116
- * Duck-typed rather than `instanceof`: the SDK runs in WebViews where `OffscreenCanvas` and
117
- * `ImageBitmap` may be missing as globals, and an `instanceof` against an absent global throws.
118
- * @param source - The source to measure.
119
- * @returns Its intrinsic size, `0 × 0` for an image element that has not decoded yet.
95
+ * The worker decodes every source itself and builds its own offscreen canvases, so this tier
96
+ * ignores {@link AtlasPackerOptions.createCanvas}. A test that passes one is testing the inline
97
+ * tier: {@link selectTier} never picks this tier in jsdom, which has neither `Worker` nor
98
+ * `OffscreenCanvas`.
99
+ * @param entries - The unique sources, and the defs that draw each one.
100
+ * @param sources - The unique sources, aligned by index with `entries`.
101
+ * @param maxDimension - The largest atlas edge, in pixels.
102
+ * @param signal - Abandons the pack. The worker is told to drop it and goes on running.
103
+ * @returns One entry for each atlas, in packing order.
120
104
  */
121
- export function imageSourceSize(source) {
122
- // `naturalWidth` is the decoded size; an `<img>`'s `width` is its layout size, which is 0 for an
123
- // element that was never in the document — the usual case for images loaded purely as textures.
124
- const image = source;
125
- return typeof image.naturalWidth === 'number'
126
- ? { width: image.naturalWidth, height: image.naturalHeight }
127
- : { width: source.width, height: source.height };
105
+ async function packWorkerTier(entries, sources, maxDimension, signal) {
106
+ // `selectTier` picked this tier only because every source is a `Blob`, which is what a
107
+ // `WorkerSource` is. The compiler cannot follow that, so the cast states it.
108
+ const { atlases, reports } = await packInWorker(sources, maxDimension, {
109
+ signal,
110
+ });
111
+ // `packInWorker` already rejects on abort, so the pack still stands here. The code checks again
112
+ // because an abort raised in the same turn as the reply is a real ordering.
113
+ if (signal?.aborted) {
114
+ // Nothing will mount these atlases, and an `ImageBitmap` holds its pixels outside the
115
+ // JavaScript heap, so close them here.
116
+ for (const atlas of atlases)
117
+ atlas.bitmap.close();
118
+ throw new Error('atlas-packer: the pack was aborted');
119
+ }
120
+ reportSources(entries, reports);
121
+ return atlases.map((atlas) => ({
122
+ image: atlas.bitmap,
123
+ resizable: atlas.resizable,
124
+ images: expandPlacements(atlas.placements, entries, reports),
125
+ }));
126
+ }
127
+ /**
128
+ * Packs on the main thread. This tier decodes the sources here and draws the atlases here.
129
+ * @param entries - The unique sources, and the defs that draw each one.
130
+ * @param sources - The unique sources, aligned by index with `entries`.
131
+ * @param maxDimension - The largest atlas edge, in pixels.
132
+ * @param createCanvas - Makes each atlas canvas.
133
+ * @param signal - Abandons the pack.
134
+ * @returns One entry for each atlas, in packing order.
135
+ */
136
+ async function packInlineTier(entries, sources, maxDimension, createCanvas, signal) {
137
+ const decoded = await decodeSources(sources);
138
+ // The caller abandoned the pack while the decodes were in flight, so nothing here is wanted any
139
+ // more. The code reads `signal.aborted` and not `throwIfAborted()`, which Chrome 66 and Safari
140
+ // 15 do not have.
141
+ if (signal?.aborted) {
142
+ closeDecoded(sources, decoded);
143
+ throw new Error('atlas-packer: the pack was aborted');
144
+ }
145
+ const { atlases, reports } = packSources(decoded, maxDimension, createCanvas);
146
+ // The atlases hold the pixels now, so the decoded bitmaps are dead.
147
+ closeDecoded(sources, decoded);
148
+ reportSources(entries, reports);
149
+ return atlases.map((atlas) => ({
150
+ image: atlas.canvas,
151
+ resizable: atlas.resizable,
152
+ images: expandPlacements(atlas.placements, entries, reports),
153
+ }));
128
154
  }
129
155
  /**
130
- * Groups defs by the source they draw, so identical images are packed once.
156
+ * Groups defs by the source they draw, so the pack draws identical images once.
131
157
  *
132
- * Identity is the URL for an image element and the object itself for a canvas or bitmap — a `Map`
133
- * never confuses the two, since a string key cannot collide with an object one. An `<img>` with no
134
- * `src` keys on itself instead, so a set of blank images is not collapsed into a single region.
158
+ * Identity is the source object itself. Two defs share a region only when they hold the exact same
159
+ * object. That is why floorplan hands two booths with one logo a single `Blob`, instead of two
160
+ * Blobs that hold the same bytes.
135
161
  * @param defs - The defs to group.
136
162
  * @returns One entry per unique source, in first-seen order.
137
163
  */
138
164
  function collectSources(defs) {
139
165
  const sources = new Map();
140
166
  for (const def of defs) {
141
- const src = def.source.src;
142
- const key = typeof src === 'string' && src !== '' ? src : def.source;
143
- let entry = sources.get(key);
167
+ let entry = sources.get(def.source);
144
168
  if (!entry) {
145
- entry = {
146
- source: def.source,
147
- ...imageSourceSize(def.source),
148
- defs: [],
149
- bounds: { width: 0, height: 0 },
150
- };
151
- sources.set(key, entry);
169
+ entry = { source: def.source, defs: [], bounds: { width: 0, height: 0 } };
170
+ sources.set(def.source, entry);
152
171
  }
153
172
  entry.defs.push(def);
154
- // Copied rather than referenced: `Rect` exposes an in-place `set`, so holding onto its size
155
- // vector would let a later bounds change rewrite what was measured here.
173
+ // The code copies this size instead of keeping a reference to it. `Rect` exposes an in-place
174
+ // `set` method. A kept reference lets a later bounds change rewrite what this function
175
+ // already measured.
156
176
  const { width, height } = def.bounds.size;
157
177
  if (width * height > entry.bounds.width * entry.bounds.height) {
158
178
  entry.bounds = { width, height };
159
179
  }
160
180
  }
161
- return sources;
181
+ return [...sources.values()];
162
182
  }
163
183
  /**
164
- * Turns each unique source into the one rect that offers it to the packer whole, dropping the ones
165
- * with no pixels to draw and flagging the ones carrying more resolution than they can show.
166
- * @param sources - The unique sources to offer.
167
- * @returns The rects to pack, and what was dropped for having no pixels.
184
+ * Turns the packer's index-addressed placements into one entry per def. Deduped defs all share
185
+ * the one region, which is the whole point of deduping them.
186
+ * @param placements - One atlas's placements.
187
+ * @param entries - The unique sources, aligned by index with what the code gave the packer.
188
+ * @param reports - Per-source measurements, aligned by index with `entries`. Supplies each
189
+ * placement's `size`.
190
+ * @returns One packed image for each def at each placement.
168
191
  */
169
- function sourceRects(sources) {
170
- const rects = [];
171
- const skipped = { defs: 0, sources: 0 };
172
- for (const entry of sources) {
173
- // A `drawImage` with a zero-width or zero-height source region throws in some browsers, so an
174
- // image that has not decoded (or a canvas sized to nothing) is dropped rather than drawn.
175
- if (entry.width <= 0 || entry.height <= 0) {
176
- skipped.sources++;
177
- skipped.defs += entry.defs.length;
178
- logger.warn('skipping source with no pixels (%dx%d) %O', entry.width, entry.height, entry.source);
179
- continue;
192
+ function expandPlacements(placements, entries, reports) {
193
+ const images = [];
194
+ for (const { index, uv, source } of placements) {
195
+ const { width, height } = reports[index];
196
+ const size = { width, height };
197
+ for (const def of entries[index].defs) {
198
+ images.push(source ? { def, uv, source, size } : { def, uv, size });
180
199
  }
181
- warnIfOverSampled(entry);
182
- rects.push({
183
- width: entry.width,
184
- height: entry.height,
185
- x: 0,
186
- y: 0,
187
- hash: `${rects.length}`,
188
- data: { entry },
189
- });
190
200
  }
191
- return { rects, skipped };
192
- }
193
- /**
194
- * Reports a source carrying far more pixels than the bounds it is drawn into can ever show. Once
195
- * per source rather than once per def, since deduped defs all share the one texture region.
196
- * @param entry - The source to check.
197
- */
198
- function warnIfOverSampled(entry) {
199
- const boundsArea = entry.bounds.width * entry.bounds.height;
200
- // Degenerate bounds would divide out to Infinity and warn about every such def, which says
201
- // nothing about resolution — that is a bounds bug, and not this function's to report.
202
- if (boundsArea <= 0)
203
- return;
204
- const ratio = (entry.width * entry.height) / boundsArea;
205
- if (ratio <= MAX_AREA_RATIO)
206
- return;
207
- logger.warn('over-sampled source: %dx%d px drawn into %sx%s units — %d px per unit², limit %d', entry.width, entry.height,
208
- // At most two decimals, trailing zeros dropped: 4 → 4, 4.1 → 4.1, 4.2999 → 4.3.
209
- Number(entry.bounds.width.toFixed(2)), Number(entry.bounds.height.toFixed(2)), Math.round(ratio), MAX_AREA_RATIO);
201
+ return images;
210
202
  }
211
203
  /**
212
- * Cuts a source the packer could not fit into a grid of tiles no larger than one atlas. Tiles are
213
- * exact and non-overlapping — each ends up alone (or beside its own siblings) in an atlas, so
214
- * nothing can bleed across the cut and no overlap column is needed to hide it.
215
- * @param rect - The oversized rect, holding the source to cut.
216
- * @param limit - Largest tile edge in pixels.
217
- * @returns The tiles to pack in its place.
204
+ * Reports what the pack was unable to use. This covers sources with no pixels, and sources that
205
+ * carry far more resolution than their bounds can ever show. The function warns once for each
206
+ * source, not once for each def, because deduped defs all share the one texture region.
207
+ * @param entries - The unique sources, aligned by index with `reports`.
208
+ * @param reports - What the packer measured and placed.
218
209
  */
219
- function cutTiles(rect, limit) {
220
- const { entry } = rect.data;
221
- const columns = Math.ceil(entry.width / limit);
222
- const rows = Math.ceil(entry.height / limit);
223
- logger.warn('source %dx%d exceeds the %dpx atlas limit, splitting into %d tiles', entry.width, entry.height, limit, columns * rows);
224
- const tiles = [];
225
- for (let row = 0; row < rows; row++) {
226
- const y = row * limit;
227
- const height = Math.min(limit, entry.height - y);
228
- for (let column = 0; column < columns; column++) {
229
- const x = column * limit;
230
- const width = Math.min(limit, entry.width - x);
231
- tiles.push({
232
- width,
233
- height,
234
- x: 0,
235
- y: 0,
236
- hash: `${rect.hash}:${column},${row}`,
237
- data: { entry, source: { x, y, width, height } },
238
- });
210
+ function reportSources(entries, reports) {
211
+ for (let index = 0; index < entries.length; index++) {
212
+ const entry = entries[index];
213
+ const { width, height, tiles } = reports[index];
214
+ if (tiles === 0) {
215
+ logger.warn('skipping source with no pixels (%dx%d) %O', width, height, entry.source);
216
+ continue;
217
+ }
218
+ if (tiles > 1) {
219
+ logger.warn('source %dx%d exceeds the atlas limit, split into %d tiles', width, height, tiles);
239
220
  }
221
+ const boundsArea = entry.bounds.width * entry.bounds.height;
222
+ // Degenerate bounds divide out to Infinity. That triggers a warning for every such def, but
223
+ // the warning says nothing about resolution. It only points to a bug in the bounds, and this
224
+ // function does not report that kind of bug.
225
+ if (boundsArea <= 0)
226
+ continue;
227
+ const ratio = (width * height) / boundsArea;
228
+ if (ratio <= MAX_AREA_RATIO)
229
+ continue;
230
+ logger.warn('over-sampled source: %dx%d px drawn into %sx%s units — %d px per unit², limit %d', width, height,
231
+ // At most two decimals, trailing zeros dropped: 4 → 4, 4.1 → 4.1, 4.2999 → 4.3.
232
+ Number(entry.bounds.width.toFixed(2)), Number(entry.bounds.height.toFixed(2)), Math.round(ratio), MAX_AREA_RATIO);
240
233
  }
241
- return tiles;
242
234
  }
243
235
  /**
244
- * Draws one packed bin onto a canvas and maps its rects back onto the defs that wanted them. The
245
- * `resizable` flag is not this function's to set — {@link packGroup} stamps it from the group's kind.
246
- * @param bin - The packed bin, already sized to its content.
247
- * @param createCanvas - Makes the canvas to draw on.
248
- * @returns The atlas, less its `resizable` flag.
236
+ * Decodes every source that is not drawable yet, and leaves the drawable ones as they are.
237
+ *
238
+ * The code starts every decode before it waits for any one of them, so the browser decoder
239
+ * overlaps them. Each decode gives back a bitmap. The packer draws that bitmap, and the caller
240
+ * closes it.
241
+ *
242
+ * One source that does not decode must not take the layer with it. A drawing can carry an
243
+ * `<image>` whose bytes are not an image at all. A `Promise.all` over the decodes rejects the whole
244
+ * pack, so a plan with one bad graphic draws none of its graphics. A failed decode becomes `null`
245
+ * instead. {@link packSources} already treats `null` as a source with no pixels: it skips that
246
+ * source, and `reportSources` warns about it by name.
247
+ * @param sources - The unique sources to decode.
248
+ * @returns The decoded sources, aligned by index with the input. An entry is `null` where the bytes
249
+ * did not decode.
249
250
  */
250
- function renderAtlas(bin, createCanvas) {
251
- const canvas = createCanvas(bin.width, bin.height);
252
- const context = contextOf(canvas);
253
- // Nothing downstream can recover from this: every atlas would upload blank, so the images would
254
- // silently draw as nothing at all. Better to fail where the cause is still visible.
255
- if (!context)
256
- throw new Error(`atlas-packer: no 2d context for a ${bin.width}x${bin.height} atlas`);
257
- const images = [];
258
- for (const rect of bin.rects) {
259
- const { entry, source } = rect.data;
260
- // The copied region is the tile when there is one and the whole image otherwise — both read
261
- // from the source's own axes. It happens to equal the packed rect's size today (tiles are
262
- // placed 1:1, unrotated), but taking it from the destination would only be right by accident.
263
- // Kept apart from `source` itself, which stays undefined for an unsplit image so the placement
264
- // below does not report a region the caller has no use for.
265
- const region = source ?? { x: 0, y: 0, width: entry.width, height: entry.height };
266
- context.drawImage(entry.source, region.x, region.y, region.width, region.height, rect.x, rect.y, rect.width, rect.height);
267
- // The rect is in canvas pixels and uv keeps the canvas's axes, so this is a plain normalization
268
- // with no vertical inversion (see {@link UvRect}). Shared by every def on this rect — dedup is
269
- // the whole point.
270
- const uv = {
271
- u0: rect.x / bin.width,
272
- v0: rect.y / bin.height,
273
- u1: (rect.x + rect.width) / bin.width,
274
- v1: (rect.y + rect.height) / bin.height,
275
- };
276
- for (const def of entry.defs)
277
- images.push(source ? { def, uv, source } : { def, uv });
278
- }
279
- return { canvas, images };
251
+ async function decodeSources(sources) {
252
+ return Promise.all(sources.map((source) => decodeSource(source).catch(() => null)));
280
253
  }
281
254
  /**
282
- * Makes an atlas canvas in the document, the default when the caller supplies no factory.
283
- * @param width - The atlas width in pixels.
284
- * @param height - The atlas height in pixels.
285
- * @returns The canvas.
255
+ * Frees the bitmaps that this pack decoded.
256
+ *
257
+ * An `ImageBitmap` holds its pixels outside the JavaScript heap, and garbage collection does not
258
+ * reclaim that memory promptly. A pack of a thousand logos would otherwise sit on a thousand
259
+ * decoded images until it did.
260
+ *
261
+ * The pack closes only what it decoded itself, which is every source that arrived as a `Blob`. A
262
+ * def is free to hold an `ImageBitmap` of its own, and {@link decodeSource} hands such a source
263
+ * straight back. Those pixels belong to the def: the next repack draws them again, and a pack that
264
+ * closed them would empty the layer instead.
265
+ * @param sources - The unique sources, aligned by index with `decoded`.
266
+ * @param decoded - What each source decoded into.
286
267
  */
287
- function createDomCanvas(width, height) {
288
- const canvas = document.createElement('canvas');
289
- canvas.width = width;
290
- canvas.height = height;
291
- return canvas;
268
+ function closeDecoded(sources, decoded) {
269
+ for (let index = 0; index < decoded.length; index++) {
270
+ const source = decoded[index];
271
+ if (source && isBlob(sources[index]) && 'close' in source)
272
+ source.close();
273
+ }
292
274
  }
293
275
  /**
294
- * Resolves an atlas canvas's 2D context, asking one canvas kind at a time.
295
- *
296
- * Deliberately not `canvas.getContext('2d')` on the union. The two kinds' overload lists disagree
297
- * at the end — `HTMLCanvasElement` closes with `getContext(id: string): RenderingContext | null`,
298
- * `OffscreenCanvas` with an `OffscreenRenderingContextId` one — and resolving across the union can
299
- * combine one kind's `"2d"` overload with the other's catch-all, yielding
300
- * `OffscreenCanvasRenderingContext2D | RenderingContext`: a type covering bitmap and WebGL contexts
301
- * that have no `drawImage`. It surfaces only on incremental rebuilds (any compile error in this
302
- * file leaves the bad resolution cached in `.tsbuildinfo`, where it outlives the error that caused
303
- * it), so a clean build hides it and a working tree quietly breaks. Narrowed first, each branch has
304
- * one concrete type whose `"2d"` overload is the only candidate, and the result is stable either
305
- * way.
306
- * @param canvas - The atlas canvas.
307
- * @returns Its 2D context, or `null` when the canvas cannot provide one.
276
+ * Decodes one source. Anything that is not a `Blob` is drawable already and comes back as it went
277
+ * in. A `Blob` goes through `createImageBitmap`, which every build target has.
278
+ * @param source - The source to decode.
279
+ * @returns A source that the packer can draw.
308
280
  */
309
- function contextOf(canvas) {
310
- // `in` rather than `instanceof`: in a worker there is no `HTMLCanvasElement` global to test
311
- // against, and an `instanceof` against a missing global throws.
312
- if ('convertToBlob' in canvas)
313
- return canvas.getContext('2d');
314
- return canvas.getContext('2d');
281
+ async function decodeSource(source) {
282
+ // A `Blob` is the only member of the union with no size and no draw. Everything else is drawable
283
+ // already.
284
+ if (!isBlob(source))
285
+ return source;
286
+ return createImageBitmap(source);
315
287
  }