@expofp/renderer 3.37.1 → 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.
package/README.md CHANGED
@@ -109,3 +109,37 @@ Two browser extensions make what the renderer draws inspectable:
109
109
  ## Running unit tests
110
110
 
111
111
  Run `nx test @expofp/renderer` to execute the unit tests via [Vitest](https://vitest.dev/).
112
+
113
+ ## Limits of the published package
114
+
115
+ **`dist` is not supported outside this repository. It does not even resolve.**
116
+ `dist/objects/images/worker/client.js` holds
117
+ `import AtlasWorker from './atlas-worker?worker&inline'`, and `dist/index.js`
118
+ reaches that file. The `?worker&inline` specifier is a Vite instruction, so
119
+ only Vite can answer it. Any other consumer — a different bundler, plain Node,
120
+ a browser loading the files as they are — fails to resolve the import and stops
121
+ there. Nothing in the package loads, so there is no smaller part of it to fall
122
+ back on.
123
+
124
+ This package has no bundler. Its `build` target is plain `tsc`, so `dist` is
125
+ file-per-module TypeScript output and nothing else runs over it. `tsc` copies
126
+ the specifier through unchanged, which is where the broken import comes from.
127
+ The same absence of a bundler means the React Compiler never runs on `dist`
128
+ either, so the components there ship unoptimized.
129
+
130
+ Nothing in this repository is affected, because nothing in this repository
131
+ consumes `dist`. The renderer is compiled from its own source instead.
132
+ Floorplan's browser build reaches `src` through the `@expofp/source` condition
133
+ in `vite.config.mts`, so Vite answers the specifier and inlines the worker
134
+ script into the bundle; floorplan also applies its own React Compiler pass.
135
+ This package's own unit tests and Storybook import `src` by relative path, so
136
+ Vite answers the specifier for them too: the Storybook run starts the real
137
+ worker, and the unit tests drive the protocol against a fake one. Wayfinding
138
+ imports types only, which are erased at emit. The offline CLI copies
139
+ floorplan's browser bundle rather than importing anything.
140
+
141
+ To lift the limit, give this package a Vite library build in the shape
142
+ floorplan already uses. Keep `build` (tsc) for the types. Add `build-browser`
143
+ (Vite) for the bundle. Put a `browser` condition in the exports map, above
144
+ `import`. That answers the worker specifier and runs the React Compiler, so
145
+ both problems above go with it.
@@ -1,7 +1,6 @@
1
1
  import { memo } from 'react';
2
2
  import { Matrix4 } from 'three';
3
3
  import { shapeToMatrix, useDefSync, writeMatrixIfChanged } from '../containers';
4
- import { imageSourceSize } from './atlas-packer';
5
4
  /**
6
5
  * Keeps one image placement current: claims a slot on mount (where the container writes the fixed
7
6
  * uv region), writes the def's transform and visibility on mount and after every `update(def)`, and
@@ -47,26 +46,27 @@ function writeImageMatrix(mesh, slot, def, packed) {
47
46
  const [originX, originY] = def.origin ?? DEFAULT_ORIGIN;
48
47
  matrix.multiply(_origin.makeTranslation(0.5 - originX, 0.5 - originY, 0));
49
48
  if (packed.source)
50
- applyTile(matrix, packed.source, def);
49
+ applyTile(matrix, packed);
51
50
  writeMatrixIfChanged(mesh, slot, matrix);
52
51
  }
53
52
  /**
54
53
  * Post-multiplies the image transform by the local sub-quad a tile occupies, so a split source's
55
- * tiles reassemble into the whole image. The tile's fraction of the source (in source pixels) is
56
- * its fraction of the image, so the sub-quad is that fraction of the unit plane — scaled to the
57
- * tile's size and translated to its center, both in unit-plane coordinates.
54
+ * tiles reassemble into the whole image. The tile's fraction of the source, in source pixels, is
55
+ * also its fraction of the image. So the sub-quad is that same fraction of the unit plane. The
56
+ * code scales the sub-quad to the tile's size, then moves it so its center lands on that
57
+ * fraction's center point, in unit-plane coordinates.
58
58
  * @param matrix - The image transform, multiplied in place.
59
- * @param source - The tile's region of the source, in source pixels.
60
- * @param def - The def whose source supplies the total pixel dimensions.
59
+ * @param packed - This instance's placement, with the tile's region and the source size to divide
60
+ * by.
61
61
  */
62
- function applyTile(matrix, source, def) {
63
- const { width, height } = imageSourceSize(def.source);
64
- if (width <= 0 || height <= 0)
62
+ function applyTile(matrix, packed) {
63
+ const { source, size } = packed;
64
+ if (!source || size.width <= 0 || size.height <= 0)
65
65
  return;
66
- const fx0 = source.x / width;
67
- const fy0 = source.y / height;
68
- const fx1 = (source.x + source.width) / width;
69
- const fy1 = (source.y + source.height) / height;
66
+ const fx0 = source.x / size.width;
67
+ const fy0 = source.y / size.height;
68
+ const fx1 = (source.x + source.width) / size.width;
69
+ const fy1 = (source.y + source.height) / size.height;
70
70
  _tile
71
71
  .makeScale(fx1 - fx0, fy1 - fy0, 1)
72
72
  .setPosition((fx0 + fx1) / 2 - 0.5, (fy0 + fy1) / 2 - 0.5, 0);
@@ -1,5 +1,6 @@
1
1
  import { type ImageDef } from '../../types/defs';
2
2
  import { type DefsProps } from '../containers';
3
+ import { type CreateAtlasCanvas } from './atlas-packer';
3
4
  /** Props for {@link Images}. */
4
5
  export interface ImagesProps extends DefsProps<ImageDef> {
5
6
  /**
@@ -8,6 +9,12 @@ export interface ImagesProps extends DefsProps<ImageDef> {
8
9
  * value to cap atlas memory below it, or to force splitting deterministically in a test.
9
10
  */
10
11
  maxAtlasSize?: number;
12
+ /**
13
+ * Makes each atlas canvas. Defaults to {@link createAtlasCanvas}, which builds an
14
+ * `OffscreenCanvas` wherever that global exists and a DOM canvas otherwise. The prop exists so
15
+ * that a test can pack without a real 2D context. It is not part of the public scene API.
16
+ */
17
+ createCanvas?: CreateAtlasCanvas;
11
18
  }
12
19
  /**
13
20
  * Draws a collection of images as textured quads, packing their pixels into one or more atlases and
@@ -30,6 +37,10 @@ export interface ImagesProps extends DefsProps<ImageDef> {
30
37
  * index) but is best-effort **across** atlases: separate meshes draw in atlas order, which packing
31
38
  * decides, not paint order. Overlapping images in one layer that pack onto different atlases is the
32
39
  * one case this does not order — rare for the markers, dots, and logos an image layer holds.
40
+ *
41
+ * Packing is asynchronous, so a layer draws nothing until its first pack settles, which is one
42
+ * microtask after the defs arrive. A repack that supersedes a pack still in flight aborts that
43
+ * pack, and closes whatever it produced.
33
44
  * @param props - The images to draw.
34
45
  * @returns One {@link ImageAtlas} per packed atlas.
35
46
  */
@@ -3,12 +3,15 @@ import { useThree } from '@react-three/fiber';
3
3
  import { use, useEffect, useRef, useState } from 'react';
4
4
  import { CanvasTexture, SRGBColorSpace } from 'three';
5
5
  import { InstancedMesh } from '../../core/instanced/InstancedMesh';
6
+ import { createLogger } from '../../utils/logging';
6
7
  import { defKey, useContainer } from '../containers';
7
8
  import { TextureMaterial } from '../materials/TextureMaterial';
8
9
  import { packAtlases } from './atlas-packer';
9
10
  import { ImageContainer, UV_OFFSET_UNIFORM } from './image-container';
10
11
  import { ImageInstance } from './ImageInstance';
12
+ import { contextOf, createAtlasCanvas } from './pack-sources';
11
13
  import { TextureBudgetContext, useResizeFactor } from './texture-budget';
14
+ const logger = createLogger('images');
12
15
  /**
13
16
  * Draws a collection of images as textured quads, packing their pixels into one or more atlases and
14
17
  * drawing each atlas as a single `InstancedMesh` — so a layer of many images costs a handful of
@@ -30,29 +33,77 @@ import { TextureBudgetContext, useResizeFactor } from './texture-budget';
30
33
  * index) but is best-effort **across** atlases: separate meshes draw in atlas order, which packing
31
34
  * decides, not paint order. Overlapping images in one layer that pack onto different atlases is the
32
35
  * one case this does not order — rare for the markers, dots, and logos an image layer holds.
36
+ *
37
+ * Packing is asynchronous, so a layer draws nothing until its first pack settles, which is one
38
+ * microtask after the defs arrive. A repack that supersedes a pack still in flight aborts that
39
+ * pack, and closes whatever it produced.
33
40
  * @param props - The images to draw.
34
41
  * @returns One {@link ImageAtlas} per packed atlas.
35
42
  */
36
43
  export function Images(props) {
37
- const { defs, maxAtlasSize } = props;
44
+ const { defs, maxAtlasSize, createCanvas } = props;
38
45
  // Handed to each atlas as a prefix of *its* placements rather than apportioned between them, and
39
46
  // a split source has several placements per def — so a slice mounts somewhere between one and a
40
47
  // few instances per granted child. The scheduler measures what a slice cost and sizes the next
41
48
  // one from that, which is what keeps the budget whatever the ratio turns out to be here.
42
49
  const mounted = props.mounted ?? defs.length;
43
50
  const gl = useThree((state) => state.gl);
51
+ const invalidate = useThree((state) => state.invalidate);
44
52
  const maxTextureSize = maxAtlasSize ?? gl.capabilities.maxTextureSize;
45
- // Repack when the *set* of images changes, via the "adjust state on prop change" pattern. The
46
- // comparison is by content, not identity: `useStagedChildren` rebuilds its snapshot on *every*
47
- // `update(layer)` — including one that only flipped the layer's own `hidden` — so identity alone
48
- // would repack for changes that touched no image at all. Order is excluded on purpose: packing
49
- // reads only pixels, so a reorder produces the same atlases and needs no repack, only the
50
- // paint-order relabel each atlas does below. Set equality is O(n) over a list already in cache,
51
- // against a repack that redraws every canvas.
52
- const [packed, setPacked] = useState(() => packImages(defs, maxTextureSize, 0));
53
- if (!sameSet(packed.defs, defs)) {
54
- setPacked((current) => packImages(defs, maxTextureSize, current.generation + 1));
55
- }
53
+ // Repack when the *set* of images changes, through the "adjust state on prop change" pattern. The
54
+ // comparison reads content and not identity. `useStagedChildren` rebuilds its snapshot on *every*
55
+ // `update(layer)`, even one that only flipped the layer's own `hidden`, so identity alone would
56
+ // repack for changes that touched no image. Order is excluded on purpose. Packing reads only
57
+ // pixels, so a reorder produces the same atlases and needs no repack, only the paint-order
58
+ // relabel that each atlas does below. Set equality costs O(n) over a list already in cache, and a
59
+ // repack redraws every canvas.
60
+ const [target, setTarget] = useState(defs);
61
+ if (!sameSet(target, defs))
62
+ setTarget(defs);
63
+ const [packed, setPacked] = useState(NO_ATLASES);
64
+ // Pack the current target, and abandon the pack when the target moves before that pack settles. A
65
+ // pack that already produced atlases when it is abandoned closes them here. Nothing mounted them,
66
+ // so nothing else will.
67
+ //
68
+ // No abort reaches that branch today, on either tier. Everything from the pack's own result down
69
+ // to the handler below is one uninterrupted drain of microtasks, and the only call to `abort` is
70
+ // this effect's cleanup, which runs in a task. So an abort lands before the result, and
71
+ // `packAtlases` rejects instead; or it lands after `adopted` is set, and the cleanup does not
72
+ // abort at all. The branch stays as insurance. One task boundary added anywhere in that chain
73
+ // opens the window, and the cost of finding out late is a leaked set of atlases.
74
+ useEffect(() => {
75
+ const controller = new AbortController();
76
+ let adopted = false;
77
+ packAtlases(target, maxTextureSize, { createCanvas, signal: controller.signal }).then((atlases) => {
78
+ if (controller.signal.aborted) {
79
+ closeAtlases(atlases);
80
+ return;
81
+ }
82
+ adopted = true;
83
+ setPacked((current) => ({
84
+ defs: target,
85
+ atlases,
86
+ generation: current.generation + 1,
87
+ }));
88
+ invalidate();
89
+ }, (error) => {
90
+ // An abort is the normal end of a superseded pack, and not a fault worth reporting.
91
+ if (!controller.signal.aborted)
92
+ logger.error('atlas pack failed %O', error);
93
+ });
94
+ return () => {
95
+ if (!adopted)
96
+ controller.abort();
97
+ };
98
+ }, [target, maxTextureSize, createCanvas, invalidate]);
99
+ // Free the pixels of a pack that a later pack superseded, and of the pack that is mounted when
100
+ // this layer goes away. The effect is keyed on the pack object, so its cleanup holds the pack it
101
+ // closes and never the pack that is mounted now. The initial state holds no atlases, so
102
+ // StrictMode's simulated remount cycles this effect once with that initial value and closes
103
+ // nothing.
104
+ useEffect(() => {
105
+ return () => closeAtlases(packed.atlases);
106
+ }, [packed]);
56
107
  return (_jsx(_Fragment, { children: packed.atlases.map((atlas) => (_jsx(ImageAtlas, { atlas: atlas, defs: defs, mounted: mounted }, atlasKey(atlas, packed.generation)))) }));
57
108
  }
58
109
  /**
@@ -74,12 +125,12 @@ function ImageAtlas(props) {
74
125
  const { atlas, defs } = props;
75
126
  // Only the placements are staged; the container and the texture are built from the whole atlas.
76
127
  const mounted = props.mounted ?? atlas.images.length;
77
- const [texture] = useState(() => makeAtlasTexture(atlas.canvas));
128
+ const [texture] = useState(() => makeAtlasTexture(atlas.image));
78
129
  const container = useContainer(() => new ImageContainer(atlas), defs);
79
130
  const invalidate = useThree((state) => state.invalidate);
80
131
  const budget = use(TextureBudgetContext);
81
132
  const factor = useResizeFactor(budget, atlas);
82
- const displayRef = useRef(undefined);
133
+ const scaledRef = useRef({});
83
134
  // Only the texture is ours to free — the mesh, its quad, and its material are all R3F's.
84
135
  // StrictMode's simulated unmount disposes then remounts, but three's `dispose` only frees GPU
85
136
  // resources — the state-retained texture re-uploads from its canvas on the next render — so the
@@ -88,12 +139,24 @@ function ImageAtlas(props) {
88
139
  invalidate();
89
140
  return () => texture.dispose();
90
141
  }, [texture, invalidate]);
91
- // Re-derive the uploaded pixels for the current resize factor. Runs on mount and every factor
92
- // move; a fixed atlas reads `1` forever, so this settles once for it and never fires again.
142
+ // Re-derive the uploaded pixels for the current resize factor. This runs on mount and on every
143
+ // factor change. A fixed atlas always reads a factor of `1`, so this settles once for it and
144
+ // never fires again.
93
145
  useEffect(() => {
94
- resizeTexture(texture, atlas.canvas, displayRef, factor);
146
+ resizeTexture(texture, atlas.image, scaledRef, factor);
95
147
  invalidate();
96
148
  }, [texture, atlas, factor, invalidate]);
149
+ // The smaller copy belongs to this component, not to the atlas, so this effect frees it. This is
150
+ // safe under StrictMode's simulated unmount. The next `resizeTexture` call rebuilds the copy from
151
+ // the full atlas, which stays untouched.
152
+ //
153
+ // `scaledRef.current` is read once, here, instead of inside the returned function. The ref holds
154
+ // one stable object for the component's whole life and is never pointed at a new one, so the read
155
+ // is safe either way, but reading it here is what lets the cleanup skip a ref read of its own.
156
+ useEffect(() => {
157
+ const scaled = scaledRef.current;
158
+ return () => closeScaled(scaled);
159
+ }, []);
97
160
  // `args` is a frozen snapshot so R3F never rebuilds the mesh; the `ref` arrow (not
98
161
  // `ref={container.bind}`) keeps the React Compiler from inferring `container` itself as a ref and
99
162
  // rejecting its render reads.
@@ -136,34 +199,48 @@ function atlasKey(atlas, generation) {
136
199
  return `${generation}:${defKey(first.def)}:${first.uv.u0}:${first.uv.v0}${region}`;
137
200
  }
138
201
  /**
139
- * Packs a set of images into atlases. Paint order is not recorded here — it is a layer-wide property
140
- * the atlases relabel from the current `defs` on every update, so a reorder never repacks.
141
- * @param defs - The images to pack.
142
- * @param maxDimension - Largest atlas edge, the GPU's max texture size.
143
- * @param generation - The repack counter for this pack.
144
- * @returns The pack.
202
+ * Releases an atlas's pixel memory.
203
+ *
204
+ * A worker returns each atlas as an `ImageBitmap`, which holds its pixels outside the JavaScript
205
+ * heap, where garbage collection does not reclaim them promptly. Every path that drops an atlas must
206
+ * therefore close it: a repack that supersedes one, a canceled pack, and unmount. A canvas has no
207
+ * `close`, so the inline path passes through here and nothing happens.
208
+ * @param atlases - The atlases to release.
145
209
  */
146
- function packImages(defs, maxDimension, generation) {
147
- return { defs, atlases: packAtlases(defs, maxDimension), generation };
210
+ function closeAtlases(atlases) {
211
+ for (const atlas of atlases) {
212
+ if ('close' in atlas.image)
213
+ atlas.image.close();
214
+ }
148
215
  }
149
216
  /**
150
- * Points an atlas texture at the pixels it should upload for the current resize factor: the full
151
- * canvas at `factor >= 1`, or a downscaled copy of it below that.
217
+ * Points an atlas texture at the pixels for the current resize factor.
152
218
  *
153
- * The copy lives in `displayRef`, created on first need and resized in place after — so a factor that
154
- * later climbs back (a budget freed up) re-derives from the untouched full canvas, never upscaling
155
- * pixels already thrown away. The full canvas is retained regardless (the atlas holds it), so this
156
- * costs one extra canvas only while actually scaling. UVs are untouched throughout: they are
157
- * normalized, so the same regions address the smaller canvas unchanged.
219
+ * At `factor >= 1`, the texture points at the full atlas. Below that, it points at a downscaled
220
+ * copy.
221
+ *
222
+ * The copy lives in `scaledRef`. The function creates the copy the first time it needs one. After
223
+ * that, it resizes the same copy in place. If the factor later climbs back up because the budget
224
+ * freed up, the next downscale starts from the untouched full atlas. It never upscales pixels
225
+ * already thrown away. The atlas keeps the full atlas regardless, so a downscale costs one extra
226
+ * surface only while it is active. UVs stay the same throughout. They are normalized, so the same
227
+ * regions still address the smaller surface without change.
228
+ *
229
+ * Where `OffscreenCanvas` exists, the downscale draws into one and uploads the bitmap from its
230
+ * `transferToImageBitmap()`. The whole design avoids uploading a DOM canvas. Without
231
+ * `OffscreenCanvas` here, a downscale below a factor of 1 brings that cost back on every atlas.
158
232
  * @param texture - The atlas texture to re-point.
159
- * @param full - The full-resolution atlas canvas, never mutated.
160
- * @param displayRef - Holds the downscaled copy across factor changes.
161
- * @param factor - The linear factor to scale by, in `(0, 1]`.
233
+ * @param full - The full-resolution atlas pixels. The function never changes them.
234
+ * @param scaledRef - Holds the smaller copy across factor changes. Its object is stable for the
235
+ * caller's whole life; only the fields inside it change.
236
+ * @param factor - The linear scale factor, in `(0, 1]`.
162
237
  */
163
- function resizeTexture(texture, full, displayRef, factor) {
238
+ function resizeTexture(texture, full, scaledRef, factor) {
239
+ const scaled = scaledRef.current;
164
240
  if (factor >= 1) {
165
- // Point back at the full canvas; a copy from an earlier, smaller factor is left for GC.
241
+ // Point back at the full atlas. Close and drop a copy from an earlier, smaller factor.
166
242
  if (texture.image !== full) {
243
+ closeScaled(scaled);
167
244
  texture.image = full;
168
245
  texture.needsUpdate = true;
169
246
  }
@@ -171,41 +248,81 @@ function resizeTexture(texture, full, displayRef, factor) {
171
248
  }
172
249
  const width = Math.max(1, Math.round(full.width * factor));
173
250
  const height = Math.max(1, Math.round(full.height * factor));
174
- const display = (displayRef.current ??= document.createElement('canvas'));
251
+ const atlasCanvas = (scaled.canvas ??= createAtlasCanvas(width, height));
175
252
  // Setting the size also clears the canvas, so the downscale draws onto a blank surface.
176
- display.width = width;
177
- display.height = height;
178
- const context = display.getContext('2d');
179
- // No 2d context is the same dead end as a blank atlas: fall back to the full canvas so the images
180
- // still draw — over budget — rather than not at all.
253
+ atlasCanvas.width = width;
254
+ atlasCanvas.height = height;
255
+ const context = contextOf(atlasCanvas);
256
+ // A missing 2D context is a dead end, the same as a blank atlas. Fall back to the full atlas, so
257
+ // the images still draw over budget rather than not at all. Close the last transfer on the way
258
+ // out: the texture points at the full atlas from here on, so the `texture.image !== full` guard
259
+ // above never fires again and nothing else would ever free those pixels.
181
260
  if (!context) {
261
+ closeScaled(scaled);
182
262
  texture.image = full;
183
263
  texture.needsUpdate = true;
184
264
  return;
185
265
  }
186
266
  context.drawImage(full, 0, 0, width, height);
187
- texture.image = display;
267
+ // The old transfer's pixels are dead the moment a new one replaces them. Garbage collection does
268
+ // not reclaim an `ImageBitmap` right away.
269
+ scaled.bitmap?.close();
270
+ scaled.bitmap =
271
+ 'transferToImageBitmap' in atlasCanvas ? atlasCanvas.transferToImageBitmap() : undefined;
272
+ texture.image = scaled.bitmap ?? atlasCanvas;
188
273
  texture.needsUpdate = true;
189
274
  }
190
275
  /**
191
- * Wraps an atlas canvas as a texture for `MeshBasicMaterial.map`.
276
+ * Releases a smaller copy's transferred pixels, if the copy ever took one.
277
+ * @param scaled - The copy.
278
+ */
279
+ function closeScaled(scaled) {
280
+ scaled.bitmap?.close();
281
+ scaled.bitmap = undefined;
282
+ }
283
+ /**
284
+ * Wraps an atlas image as a texture for `MeshBasicMaterial.map`.
192
285
  *
193
- * Mipmaps stay on: a floor plan is zoomed far out as readily as far in, and without them every
194
- * image shimmers under minification. The cost is that neighbors can bleed through the deeper mip
195
- * levels, where the packer's one-pixel gutter no longer separates them.
196
- * @param canvas - The packed atlas canvas.
286
+ * Mipmaps stay on. A floor plan is zoomed far out as often as it is zoomed far in, and without
287
+ * mipmaps every image shimmers under minification. The cost is that neighbors can bleed through
288
+ * the deeper mip levels, where the packer's one-pixel gutter no longer separates them.
289
+ * @param image - The packed atlas image: a canvas, or a bitmap a worker transferred out of one.
197
290
  * @returns The texture.
198
291
  */
199
- function makeAtlasTexture(canvas) {
200
- const texture = new CanvasTexture(canvas);
292
+ function makeAtlasTexture(image) {
293
+ const texture = new CanvasTexture(image);
201
294
  // The atlas holds sRGB pixels; tagging the color space is what makes `MeshBasicMaterial` sample
202
295
  // and display them without a gamma shift.
203
296
  texture.colorSpace = SRGBColorSpace;
204
- // Upload the canvas rows in their own order rather than three's default vertical flip, so v runs
205
- // down the atlas — the same direction as the packer's uv regions and as the scene's y axis. Every
206
- // axis in the chain then points the same way; flip at one end only and images come out upside
207
- // down.
297
+ // Upload the canvas rows in their own order, instead of the vertical flip three applies by
298
+ // default. This way, v runs down the atlas, in the same direction as the packer's uv regions and
299
+ // the scene's y axis. Every axis in the chain then points the same way. If only one end flips,
300
+ // images come out upside down.
301
+ //
302
+ // This governs the inline path, where the texture uploads a canvas. For an `ImageBitmap`, this
303
+ // line does nothing: three skips it, along with two other pixel-store calls, whenever the
304
+ // texture image is an `ImageBitmap` (see `premultiplyAlpha`, below, for the one skip that is not
305
+ // harmless). Orientation then comes from how the bitmap was made. The worker draws into an
306
+ // `OffscreenCanvas` with row 0 at the top and transfers it unflipped, which matches the
307
+ // orientation `UvRect` documents. Do not delete this line: the worker path does not read it, but
308
+ // the inline path still does.
208
309
  texture.flipY = false;
310
+ // The same skip covers `UNPACK_PREMULTIPLY_ALPHA_WEBGL` and `UNPACK_COLORSPACE_CONVERSION_WEBGL`.
311
+ // Skipping the second changes nothing: it is a legacy flag for an embedded color profile, and an
312
+ // atlas canvas is drawn, never decoded from a file that could carry one. Skipping the first is
313
+ // not harmless. `transferToImageBitmap()` hands over the canvas's own backing store, which a 2D
314
+ // canvas always keeps premultiplied, and it takes no option to ask for straight alpha instead.
315
+ // With the GL call skipped, that premultiplied data reaches the texture unchanged. Setting
316
+ // `premultiplyAlpha = true` here makes the canvas path premultiply on upload too, so both paths
317
+ // agree by construction rather than by luck. `TextureMaterial` blends on that agreement: it
318
+ // expects a premultiplied source, not a straight one.
319
+ texture.premultiplyAlpha = true;
209
320
  texture.generateMipmaps = true;
210
321
  return texture;
211
322
  }
323
+ /**
324
+ * The pack that a layer starts on: none. Packing is asynchronous, so a layer draws nothing for the
325
+ * first microtask of its life. One value serves every layer, because every layer starts from the
326
+ * same nothing, and nothing writes to it.
327
+ */
328
+ const NO_ATLASES = { defs: [], atlases: [], generation: 0 };