@expofp/renderer 3.37.1 → 3.39.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.
Files changed (71) hide show
  1. package/README.md +34 -0
  2. package/dist/controls/camera-api.d.ts +28 -0
  3. package/dist/controls/camera-api.js +33 -10
  4. package/dist/controls/hooks.js +2 -0
  5. package/dist/core/InstanceUniforms.d.ts +15 -0
  6. package/dist/core/InstanceUniforms.js +32 -0
  7. package/dist/core/batch/BatchedMesh.js +6 -0
  8. package/dist/core/instanced/instanced-material.js +5 -1
  9. package/dist/core/lines/BatchedLines.d.ts +9 -7
  10. package/dist/core/lines/BatchedLines.js +9 -7
  11. package/dist/core/lines/{FrustumCulling.d.ts → FrustumCullingLines.d.ts} +7 -6
  12. package/dist/core/lines/{FrustumCulling.js → FrustumCullingLines.js} +2 -2
  13. package/dist/core/lines/{Raycasting.d.ts → RaycastingLines.d.ts} +7 -6
  14. package/dist/core/lines/{Raycasting.js → RaycastingLines.js} +2 -2
  15. package/dist/core/lines/lines-material.js +10 -1
  16. package/dist/core/sprites/BatchedSprites.d.ts +115 -0
  17. package/dist/core/sprites/BatchedSprites.js +205 -0
  18. package/dist/core/sprites/RaycastingSprites.d.ts +41 -0
  19. package/dist/core/sprites/RaycastingSprites.js +71 -0
  20. package/dist/core/sprites/sprite-material.d.ts +59 -0
  21. package/dist/core/sprites/sprite-material.js +192 -0
  22. package/dist/core/text/BatchedText.d.ts +6 -0
  23. package/dist/core/text/BatchedText.js +6 -0
  24. package/dist/core/text/MemberTexture.js +55 -1
  25. package/dist/core/text/Text.d.ts +16 -1
  26. package/dist/core/text/Text.js +15 -0
  27. package/dist/core/text/batched-text-material.js +4 -2
  28. package/dist/objects/containers/hooks.d.ts +7 -2
  29. package/dist/objects/containers/hooks.js +20 -5
  30. package/dist/objects/containers/mesh-writes.js +3 -3
  31. package/dist/objects/images/ImageInstance.js +111 -43
  32. package/dist/objects/images/Images.d.ts +19 -7
  33. package/dist/objects/images/Images.js +266 -83
  34. package/dist/objects/images/atlas-packer.d.ts +92 -91
  35. package/dist/objects/images/atlas-packer.js +230 -258
  36. package/dist/objects/images/image-container.d.ts +14 -3
  37. package/dist/objects/images/image-container.js +19 -5
  38. package/dist/objects/images/pack-sources.d.ts +190 -0
  39. package/dist/objects/images/pack-sources.js +272 -0
  40. package/dist/objects/images/texture-budget.js +2 -2
  41. package/dist/objects/images/worker/atlas-worker.d.ts +56 -0
  42. package/dist/objects/images/worker/atlas-worker.js +123 -0
  43. package/dist/objects/images/worker/client.d.ts +62 -0
  44. package/dist/objects/images/worker/client.js +249 -0
  45. package/dist/objects/images/worker/index.d.ts +10 -0
  46. package/dist/objects/images/worker/index.js +1 -0
  47. package/dist/objects/layer/Layer.d.ts +7 -6
  48. package/dist/objects/layer/Layer.js +40 -30
  49. package/dist/objects/layer/layer-context.d.ts +41 -0
  50. package/dist/objects/layer/layer-context.js +45 -0
  51. package/dist/objects/materials/ColorMaterial.js +16 -3
  52. package/dist/objects/materials/LineMaterial.js +4 -2
  53. package/dist/objects/materials/TextureMaterial.js +14 -2
  54. package/dist/objects/materials/effects/color-dim.d.ts +1 -10
  55. package/dist/objects/materials/effects/color-dim.js +21 -18
  56. package/dist/objects/materials/effects/effects-context.d.ts +6 -5
  57. package/dist/objects/materials/shared.d.ts +18 -0
  58. package/dist/objects/materials/shared.js +21 -0
  59. package/dist/objects/models/Models.js +1 -8
  60. package/dist/objects/shapes/Shapes.js +2 -9
  61. package/dist/objects/text/TextInstance.js +1 -1
  62. package/dist/objects/text/text-layout.d.ts +13 -4
  63. package/dist/objects/text/text-layout.js +30 -4
  64. package/dist/space/Space.d.ts +15 -0
  65. package/dist/space/Space.js +16 -10
  66. package/dist/types/defs.d.ts +53 -2
  67. package/dist/utils/logging.d.ts +6 -3
  68. package/dist/utils/logging.js +15 -7
  69. package/dist/utils/math.d.ts +11 -0
  70. package/dist/utils/math.js +14 -0
  71. package/package.json +2 -2
@@ -3,22 +3,27 @@ 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 { BatchedSprites } from '../../core/sprites/BatchedSprites';
7
+ import { createLogger } from '../../utils/logging';
6
8
  import { defKey, useContainer } from '../containers';
7
9
  import { TextureMaterial } from '../materials/TextureMaterial';
8
- import { packAtlases } from './atlas-packer';
10
+ import { packAtlases, } from './atlas-packer';
9
11
  import { ImageContainer, UV_OFFSET_UNIFORM } from './image-container';
10
12
  import { ImageInstance } from './ImageInstance';
13
+ import { contextOf, createAtlasCanvas } from './pack-sources';
11
14
  import { TextureBudgetContext, useResizeFactor } from './texture-budget';
15
+ const logger = createLogger('images');
12
16
  /**
13
- * Draws a collection of images as textured quads, packing their pixels into one or more atlases and
14
- * drawing each atlas as a single `InstancedMesh` — so a layer of many images costs a handful of
15
- * draw calls, not one per image.
17
+ * Draws a collection of images as textured quads, packing their pixels into one or more atlases.
18
+ * Each atlas draws through up to two meshes that share its texture, so a layer of many images still
19
+ * costs a handful of draw calls, not one per image.
16
20
  *
17
21
  * Unlike a shape layer, an image layer can span **several meshes**: bin-packing splits the images
18
- * across as many atlases as their pixels need, and each atlas gets its own mesh, batch, and
19
- * texture (an {@link ImageAtlas}). A def whose source is too big for one atlas is cut into tiles
20
- * that may land on different atlases, so one def can drive several instances — the atlas iterates
21
- * its packed placements, not the defs.
22
+ * across as many atlases as their pixels need, and each atlas gets its own texture drawn through up
23
+ * to two meshes, a flat `InstancedMesh` and a camera-facing `BatchedSprites` (an
24
+ * {@link ImageAtlas}). An unmixed layer still mounts only one mesh. A def whose source is too big
25
+ * for one atlas is cut into tiles that may land on different atlases, so one def can drive several
26
+ * instances. The atlas iterates its packed placements, not the defs.
22
27
  *
23
28
  * Packing has no incremental path, so a change to the **set** of images **repacks from scratch**: a
24
29
  * fresh atlas set under fresh keys, remounting every mesh and instance. A **reorder** of the same
@@ -30,56 +35,104 @@ import { TextureBudgetContext, useResizeFactor } from './texture-budget';
30
35
  * index) but is best-effort **across** atlases: separate meshes draw in atlas order, which packing
31
36
  * decides, not paint order. Overlapping images in one layer that pack onto different atlases is the
32
37
  * one case this does not order — rare for the markers, dots, and logos an image layer holds.
38
+ *
39
+ * Packing is asynchronous, so a layer draws nothing until its first pack settles, which is one
40
+ * microtask after the defs arrive. A repack that supersedes a pack still in flight aborts that
41
+ * pack, and closes whatever it produced.
33
42
  * @param props - The images to draw.
34
43
  * @returns One {@link ImageAtlas} per packed atlas.
35
44
  */
36
45
  export function Images(props) {
37
- const { defs, maxAtlasSize } = props;
46
+ const { defs, maxAtlasSize, createCanvas } = props;
38
47
  // Handed to each atlas as a prefix of *its* placements rather than apportioned between them, and
39
48
  // a split source has several placements per def — so a slice mounts somewhere between one and a
40
49
  // few instances per granted child. The scheduler measures what a slice cost and sizes the next
41
50
  // one from that, which is what keeps the budget whatever the ratio turns out to be here.
42
51
  const mounted = props.mounted ?? defs.length;
43
52
  const gl = useThree((state) => state.gl);
53
+ const invalidate = useThree((state) => state.invalidate);
44
54
  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
- }
55
+ // Repack when the *set* of images changes, through the "adjust state on prop change" pattern. The
56
+ // comparison reads content and not identity. `useStagedChildren` rebuilds its snapshot on *every*
57
+ // `update(layer)`, even one that only flipped the layer's own `hidden`, so identity alone would
58
+ // repack for changes that touched no image. Order is excluded on purpose. Packing reads only
59
+ // pixels, so a reorder produces the same atlases and needs no repack, only the paint-order
60
+ // relabel that each atlas does below. Set equality costs O(n) over a list already in cache, and a
61
+ // repack redraws every canvas.
62
+ const [target, setTarget] = useState(defs);
63
+ if (!sameSet(target, defs))
64
+ setTarget(defs);
65
+ const [packed, setPacked] = useState(NO_ATLASES);
66
+ // Pack the current target, and abandon the pack when the target moves before that pack settles. A
67
+ // pack that already produced atlases when it is abandoned closes them here. Nothing mounted them,
68
+ // so nothing else will.
69
+ //
70
+ // No abort reaches that branch today, on either tier. Everything from the pack's own result down
71
+ // to the handler below is one uninterrupted drain of microtasks, and the only call to `abort` is
72
+ // this effect's cleanup, which runs in a task. So an abort lands before the result, and
73
+ // `packAtlases` rejects instead; or it lands after `adopted` is set, and the cleanup does not
74
+ // abort at all. The branch stays as insurance. One task boundary added anywhere in that chain
75
+ // opens the window, and the cost of finding out late is a leaked set of atlases.
76
+ useEffect(() => {
77
+ const controller = new AbortController();
78
+ let adopted = false;
79
+ packAtlases(target, maxTextureSize, { createCanvas, signal: controller.signal }).then((atlases) => {
80
+ if (controller.signal.aborted) {
81
+ closeAtlases(atlases);
82
+ return;
83
+ }
84
+ adopted = true;
85
+ setPacked((current) => ({
86
+ defs: target,
87
+ atlases,
88
+ generation: current.generation + 1,
89
+ }));
90
+ invalidate();
91
+ }, (error) => {
92
+ // An abort is the normal end of a superseded pack, and not a fault worth reporting.
93
+ if (!controller.signal.aborted)
94
+ logger.error('atlas pack failed %O', error);
95
+ });
96
+ return () => {
97
+ if (!adopted)
98
+ controller.abort();
99
+ };
100
+ }, [target, maxTextureSize, createCanvas, invalidate]);
101
+ // Free the pixels of a pack that a later pack superseded, and of the pack that is mounted when
102
+ // this layer goes away. The effect is keyed on the pack object, so its cleanup holds the pack it
103
+ // closes and never the pack that is mounted now. The initial state holds no atlases, so
104
+ // StrictMode's simulated remount cycles this effect once with that initial value and closes
105
+ // nothing.
106
+ useEffect(() => {
107
+ return () => closeAtlases(packed.atlases);
108
+ }, [packed]);
56
109
  return (_jsx(_Fragment, { children: packed.atlases.map((atlas) => (_jsx(ImageAtlas, { atlas: atlas, defs: defs, mounted: mounted }, atlasKey(atlas, packed.generation)))) }));
57
110
  }
58
111
  /**
59
- * Draws one atlas as an `InstancedMesh`: the R3F-owned mesh with the atlas texture as its
60
- * `TextureMaterial`, plus one {@link ImageInstance} per placement on the atlas. Its set of images is
61
- * fixed for its lifetime — a repack remounts the whole component — so the batch and texture are
62
- * built once from state and released together on unmount. Paint order is not fixed: a reorder keeps
63
- * this atlas and re-labels its instances through the effect below.
112
+ * Owns one atlas's texture and its flat/sprite split, and draws each non-empty side as its own
113
+ * `AtlasMesh`. The atlas's set of images is fixed for its lifetime. A repack remounts the whole
114
+ * component, so the texture is built once from state and released on unmount.
115
+ *
116
+ * The uploaded texture is not fixed. A resizable atlas registers with the scene's texture budget and
117
+ * re-derives a downscaled copy of its canvas whenever the shared resize factor moves (see
118
+ * {@link resizeTexture}). That is a texture re-upload, not a repack. The meshes, their containers,
119
+ * their instances, and their normalized uv regions all stand, because uv does not depend on the
120
+ * canvas's pixel size.
64
121
  *
65
- * The uploaded texture is not fixed either: a resizable atlas registers with the scene's texture
66
- * budget and re-derives a downscaled copy of its canvas whenever the shared resize factor moves (see
67
- * {@link resizeTexture}). That is a texture re-upload, not a repack — the mesh, container,
68
- * instances, and their normalized uv regions all stand, since uv is invariant to the canvas's pixel
69
- * size.
122
+ * The flat/sprite split is a second kind of state, held here rather than derived every render. A
123
+ * def's `screenSpace` is an ordinary slot write that never re-renders this component, so an
124
+ * `AtlasMesh` calls `repartition` when it finds a member that no longer matches its kind.
70
125
  * @param props - The atlas and the layer's images in paint order.
71
- * @returns The mesh and one instance component per placement.
126
+ * @returns One `AtlasMesh` per non-empty kind, sharing this atlas's texture.
72
127
  */
73
128
  function ImageAtlas(props) {
74
129
  const { atlas, defs } = props;
75
- // Only the placements are staged; the container and the texture are built from the whole atlas.
76
130
  const mounted = props.mounted ?? atlas.images.length;
77
- const [texture] = useState(() => makeAtlasTexture(atlas.canvas));
78
- const container = useContainer(() => new ImageContainer(atlas), defs);
131
+ const [texture] = useState(() => makeAtlasTexture(atlas.image));
79
132
  const invalidate = useThree((state) => state.invalidate);
80
133
  const budget = use(TextureBudgetContext);
81
134
  const factor = useResizeFactor(budget, atlas);
82
- const displayRef = useRef(undefined);
135
+ const scaledRef = useRef({});
83
136
  // Only the texture is ours to free — the mesh, its quad, and its material are all R3F's.
84
137
  // StrictMode's simulated unmount disposes then remounts, but three's `dispose` only frees GPU
85
138
  // resources — the state-retained texture re-uploads from its canvas on the next render — so the
@@ -88,21 +141,97 @@ function ImageAtlas(props) {
88
141
  invalidate();
89
142
  return () => texture.dispose();
90
143
  }, [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.
144
+ // Re-derive the uploaded pixels for the current resize factor. This runs on mount and on every
145
+ // factor change. A fixed atlas always reads a factor of `1`, so this settles once for it and
146
+ // never fires again.
93
147
  useEffect(() => {
94
- resizeTexture(texture, atlas.canvas, displayRef, factor);
148
+ resizeTexture(texture, atlas.image, scaledRef, factor);
95
149
  invalidate();
96
150
  }, [texture, atlas, factor, invalidate]);
97
- // `args` is a frozen snapshot so R3F never rebuilds the mesh; the `ref` arrow (not
98
- // `ref={container.bind}`) keeps the React Compiler from inferring `container` itself as a ref and
151
+ // The smaller copy belongs to this component, not to the atlas, so this effect frees it. This is
152
+ // safe under StrictMode's simulated unmount. The next `resizeTexture` call rebuilds the copy from
153
+ // the full atlas, which stays untouched.
154
+ //
155
+ // `scaledRef.current` is read once, here, instead of inside the returned function. The ref holds
156
+ // one stable object for the component's whole life and is never pointed at a new one, so the read
157
+ // is safe either way, but reading it here is what lets the cleanup skip a ref read of its own.
158
+ useEffect(() => {
159
+ const scaled = scaledRef.current;
160
+ return () => closeScaled(scaled);
161
+ }, []);
162
+ // Held in state rather than derived each render, which is the shape `Shapes` uses for its own
163
+ // split. screenSpace is an ordinary slot write that never re-renders this component, so a def
164
+ // crossing the orientation boundary would otherwise stay in a mesh that no longer suits it.
165
+ // repartition is what an instance's reportKindMismatch runs. The atlas's placements never change
166
+ // identity, because a repack remounts this whole component, so nothing else triggers a re-split.
167
+ const [partition, setPartition] = useState(() => partitionImages(atlas.images));
168
+ /** Re-splits the atlas's placements, for a mesh that found a member of the wrong kind. */
169
+ function repartition() {
170
+ setPartition(() => partitionImages(atlas.images));
171
+ }
172
+ // One mesh per kind, keyed so React never reconciles a flat mesh element against a sprite one
173
+ // when the first kind empties out. An empty subset renders nothing, so an unmixed layer costs
174
+ // exactly what it costs today and a mixed layer costs one extra draw call per atlas.
175
+ //
176
+ // renderOrder is the kind's place in IMAGE_KINDS, which makes "flats, then sprites" an explicit
177
+ // rule within a layer. Today the atlas meshes carry no order at all and it falls out of
178
+ // material.id, which is mount order. What that costs is that a flat def and a sprite def in one
179
+ // layer can no longer interleave in array order. `overlay` is there for a caller who needs strict
180
+ // separation, and a camera-facing pin is generally meant to be on top anyway.
181
+ return (_jsx(_Fragment, { children: IMAGE_KINDS.map((kind, renderOrder) => {
182
+ const placements = partition[kind];
183
+ if (placements.length === 0)
184
+ return null;
185
+ return (_jsx(AtlasMesh, { kind: kind, placements: placements, defs: defs, mounted: mounted, capacity: atlas.images.length, texture: texture, renderOrder: renderOrder, onKindMismatch: repartition }, kind));
186
+ }) }));
187
+ }
188
+ /**
189
+ * One atlas mesh and its instances: the atlas's flat images or its camera-facing ones, never both.
190
+ *
191
+ * The material is a child and is genuinely per mesh. `InstancedMeshImpl` wraps whatever it draws with
192
+ * in a closure over its own textures, and two instanced meshes on one material would both render
193
+ * against the first one's. The texture is shared, and that is the whole of the two-mesh split: an
194
+ * atlas is a texture packing and a mesh is a draw call, and nothing forces them to be one thing.
195
+ *
196
+ * The sprite mesh mounts no plane geometry child. `BatchedSpritesImpl` seeds the shared quad
197
+ * itself, and not rendering a replacement is what makes the seeded one stand.
198
+ * @param props - The kind, its placements, and the shared texture.
199
+ * @returns The mesh and one instance component per placement.
200
+ */
201
+ function AtlasMesh(props) {
202
+ const { kind, placements, defs, capacity, texture, renderOrder, onKindMismatch } = props;
203
+ // Handed to this mesh as a prefix of its own placements rather than apportioned between the two,
204
+ // as Shapes does for its batches. A slice can overshoot the scheduler's budget by a factor of two
205
+ // at worst, and the scheduler measures what a slice actually cost and sizes the next one from it.
206
+ const mounted = props.mounted ?? placements.length;
207
+ // Sized from the atlas's whole placement count, not this subset's, so a re-split never resizes a
208
+ // buffer. The container is bookkeeping only, so the copy StrictMode's double-invoked initializer
209
+ // discards is plain garbage.
210
+ const container = useContainer(() => new ImageContainer(capacity), defs, onKindMismatch);
211
+ // args is a frozen snapshot, so R3F never rebuilds the mesh. The ref arrow, rather than
212
+ // ref={container.bind}, keeps the React Compiler from inferring container itself as a ref and
99
213
  // rejecting its render reads.
100
- // The quad and the material are children so R3F creates, attaches, and disposes both — the args
101
- // pair are placeholders the constructor demands (see `ImageMeshArgs`). The material has to be per
102
- // mesh: `InstancedMeshImpl` wraps whatever it draws with in a closure over its own textures, and two
103
- // instanced meshes on one material would both render against the first one's.
104
- return (_jsxs(_Fragment, { children: [_jsxs(InstancedMesh, { args: container.args, ref: (mesh) => container.bind(mesh), sortObjects: true, customSort: container.customSort, perObjectFrustumCulled: true, children: [_jsx("planeGeometry", {}), _jsx(TextureMaterial, { map: texture, uvOffset: UV_OFFSET_UNIFORM })] }), atlas.images.slice(0, mounted).map((packed) => (_jsx(ImageInstance, { def: packed.def, packed: packed, container: container }, `${defKey(packed.def)}:${packed.uv.u0}:${packed.uv.v0}`)))] }));
214
+ const mesh = kind === 'sprite' ? (_jsx(BatchedSprites, { args: container.args, ref: (instance) => container.bind(instance), sortObjects: true, customSort: container.customSort, perObjectFrustumCulled: true, renderOrder: renderOrder, children: _jsx(TextureMaterial, { map: texture, uvOffset: UV_OFFSET_UNIFORM }) })) : (_jsxs(InstancedMesh, { args: container.args, ref: (instance) => container.bind(instance), sortObjects: true, customSort: container.customSort, perObjectFrustumCulled: true, renderOrder: renderOrder, children: [_jsx("planeGeometry", {}), _jsx(TextureMaterial, { map: texture, uvOffset: UV_OFFSET_UNIFORM })] }));
215
+ return (_jsxs(_Fragment, { children: [mesh, placements.slice(0, mounted).map((packed) => (_jsx(ImageInstance, { def: packed.def, packed: packed, container: container }, `${defKey(packed.def)}:${packed.uv.u0}:${packed.uv.v0}`)))] }));
105
216
  }
217
+ /**
218
+ * Splits an atlas's placements by orientation.
219
+ *
220
+ * A filter over an array the component already holds. The packing, the canvas, the uv regions and the
221
+ * texture budget are all untouched, so nothing repacks. That is what makes flipping a def's
222
+ * screenSpace cheaper here than a shape's kind change, where a re-split rebuilds partitions.
223
+ * @param images - The atlas's placements, in packing order.
224
+ * @returns The two subsets, each still in packing order.
225
+ */
226
+ function partitionImages(images) {
227
+ const flat = [];
228
+ const sprite = [];
229
+ for (const packed of images)
230
+ (packed.def.screenSpace === true ? sprite : flat).push(packed);
231
+ return { flat, sprite };
232
+ }
233
+ /** The atlas's two meshes, in draw order: flats first, then the camera-facing ones. */
234
+ const IMAGE_KINDS = ['flat', 'sprite'];
106
235
  /**
107
236
  * Whether two snapshots hold the same *set* of images, order aside — the question "does this need a
108
237
  * repack". Order is excluded on purpose: packing reads only pixels, so a reorder produces the same
@@ -136,34 +265,48 @@ function atlasKey(atlas, generation) {
136
265
  return `${generation}:${defKey(first.def)}:${first.uv.u0}:${first.uv.v0}${region}`;
137
266
  }
138
267
  /**
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.
268
+ * Releases an atlas's pixel memory.
269
+ *
270
+ * A worker returns each atlas as an `ImageBitmap`, which holds its pixels outside the JavaScript
271
+ * heap, where garbage collection does not reclaim them promptly. Every path that drops an atlas must
272
+ * therefore close it: a repack that supersedes one, a canceled pack, and unmount. A canvas has no
273
+ * `close`, so the inline path passes through here and nothing happens.
274
+ * @param atlases - The atlases to release.
145
275
  */
146
- function packImages(defs, maxDimension, generation) {
147
- return { defs, atlases: packAtlases(defs, maxDimension), generation };
276
+ function closeAtlases(atlases) {
277
+ for (const atlas of atlases) {
278
+ if ('close' in atlas.image)
279
+ atlas.image.close();
280
+ }
148
281
  }
149
282
  /**
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.
283
+ * Points an atlas texture at the pixels for the current resize factor.
152
284
  *
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.
285
+ * At `factor >= 1`, the texture points at the full atlas. Below that, it points at a downscaled
286
+ * copy.
287
+ *
288
+ * The copy lives in `scaledRef`. The function creates the copy the first time it needs one. After
289
+ * that, it resizes the same copy in place. If the factor later climbs back up because the budget
290
+ * freed up, the next downscale starts from the untouched full atlas. It never upscales pixels
291
+ * already thrown away. The atlas keeps the full atlas regardless, so a downscale costs one extra
292
+ * surface only while it is active. UVs stay the same throughout. They are normalized, so the same
293
+ * regions still address the smaller surface without change.
294
+ *
295
+ * Where `OffscreenCanvas` exists, the downscale draws into one and uploads the bitmap from its
296
+ * `transferToImageBitmap()`. The whole design avoids uploading a DOM canvas. Without
297
+ * `OffscreenCanvas` here, a downscale below a factor of 1 brings that cost back on every atlas.
158
298
  * @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]`.
299
+ * @param full - The full-resolution atlas pixels. The function never changes them.
300
+ * @param scaledRef - Holds the smaller copy across factor changes. Its object is stable for the
301
+ * caller's whole life; only the fields inside it change.
302
+ * @param factor - The linear scale factor, in `(0, 1]`.
162
303
  */
163
- function resizeTexture(texture, full, displayRef, factor) {
304
+ function resizeTexture(texture, full, scaledRef, factor) {
305
+ const scaled = scaledRef.current;
164
306
  if (factor >= 1) {
165
- // Point back at the full canvas; a copy from an earlier, smaller factor is left for GC.
307
+ // Point back at the full atlas. Close and drop a copy from an earlier, smaller factor.
166
308
  if (texture.image !== full) {
309
+ closeScaled(scaled);
167
310
  texture.image = full;
168
311
  texture.needsUpdate = true;
169
312
  }
@@ -171,41 +314,81 @@ function resizeTexture(texture, full, displayRef, factor) {
171
314
  }
172
315
  const width = Math.max(1, Math.round(full.width * factor));
173
316
  const height = Math.max(1, Math.round(full.height * factor));
174
- const display = (displayRef.current ??= document.createElement('canvas'));
317
+ const atlasCanvas = (scaled.canvas ??= createAtlasCanvas(width, height));
175
318
  // 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.
319
+ atlasCanvas.width = width;
320
+ atlasCanvas.height = height;
321
+ const context = contextOf(atlasCanvas);
322
+ // A missing 2D context is a dead end, the same as a blank atlas. Fall back to the full atlas, so
323
+ // the images still draw over budget rather than not at all. Close the last transfer on the way
324
+ // out: the texture points at the full atlas from here on, so the `texture.image !== full` guard
325
+ // above never fires again and nothing else would ever free those pixels.
181
326
  if (!context) {
327
+ closeScaled(scaled);
182
328
  texture.image = full;
183
329
  texture.needsUpdate = true;
184
330
  return;
185
331
  }
186
332
  context.drawImage(full, 0, 0, width, height);
187
- texture.image = display;
333
+ // The old transfer's pixels are dead the moment a new one replaces them. Garbage collection does
334
+ // not reclaim an `ImageBitmap` right away.
335
+ scaled.bitmap?.close();
336
+ scaled.bitmap =
337
+ 'transferToImageBitmap' in atlasCanvas ? atlasCanvas.transferToImageBitmap() : undefined;
338
+ texture.image = scaled.bitmap ?? atlasCanvas;
188
339
  texture.needsUpdate = true;
189
340
  }
190
341
  /**
191
- * Wraps an atlas canvas as a texture for `MeshBasicMaterial.map`.
342
+ * Releases a smaller copy's transferred pixels, if the copy ever took one.
343
+ * @param scaled - The copy.
344
+ */
345
+ function closeScaled(scaled) {
346
+ scaled.bitmap?.close();
347
+ scaled.bitmap = undefined;
348
+ }
349
+ /**
350
+ * Wraps an atlas image as a texture for `MeshBasicMaterial.map`.
192
351
  *
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.
352
+ * Mipmaps stay on. A floor plan is zoomed far out as often as it is zoomed far in, and without
353
+ * mipmaps every image shimmers under minification. The cost is that neighbors can bleed through
354
+ * the deeper mip levels, where the packer's one-pixel gutter no longer separates them.
355
+ * @param image - The packed atlas image: a canvas, or a bitmap a worker transferred out of one.
197
356
  * @returns The texture.
198
357
  */
199
- function makeAtlasTexture(canvas) {
200
- const texture = new CanvasTexture(canvas);
358
+ function makeAtlasTexture(image) {
359
+ const texture = new CanvasTexture(image);
201
360
  // The atlas holds sRGB pixels; tagging the color space is what makes `MeshBasicMaterial` sample
202
361
  // and display them without a gamma shift.
203
362
  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.
363
+ // Upload the canvas rows in their own order, instead of the vertical flip three applies by
364
+ // default. This way, v runs down the atlas, in the same direction as the packer's uv regions and
365
+ // the scene's y axis. Every axis in the chain then points the same way. If only one end flips,
366
+ // images come out upside down.
367
+ //
368
+ // This governs the inline path, where the texture uploads a canvas. For an `ImageBitmap`, this
369
+ // line does nothing: three skips it, along with two other pixel-store calls, whenever the
370
+ // texture image is an `ImageBitmap` (see `premultiplyAlpha`, below, for the one skip that is not
371
+ // harmless). Orientation then comes from how the bitmap was made. The worker draws into an
372
+ // `OffscreenCanvas` with row 0 at the top and transfers it unflipped, which matches the
373
+ // orientation `UvRect` documents. Do not delete this line: the worker path does not read it, but
374
+ // the inline path still does.
208
375
  texture.flipY = false;
376
+ // The same skip covers `UNPACK_PREMULTIPLY_ALPHA_WEBGL` and `UNPACK_COLORSPACE_CONVERSION_WEBGL`.
377
+ // Skipping the second changes nothing: it is a legacy flag for an embedded color profile, and an
378
+ // atlas canvas is drawn, never decoded from a file that could carry one. Skipping the first is
379
+ // not harmless. `transferToImageBitmap()` hands over the canvas's own backing store, which a 2D
380
+ // canvas always keeps premultiplied, and it takes no option to ask for straight alpha instead.
381
+ // With the GL call skipped, that premultiplied data reaches the texture unchanged. Setting
382
+ // `premultiplyAlpha = true` here makes the canvas path premultiply on upload too, so both paths
383
+ // agree by construction rather than by luck. `TextureMaterial` blends on that agreement: it
384
+ // expects a premultiplied source, not a straight one.
385
+ texture.premultiplyAlpha = true;
209
386
  texture.generateMipmaps = true;
210
387
  return texture;
211
388
  }
389
+ /**
390
+ * The pack that a layer starts on: none. Packing is asynchronous, so a layer draws nothing for the
391
+ * first microtask of its life. One value serves every layer, because every layer starts from the
392
+ * same nothing, and nothing writes to it.
393
+ */
394
+ const NO_ATLASES = { defs: [], atlases: [], generation: 0 };