@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 +34 -0
- package/dist/objects/images/ImageInstance.js +14 -14
- package/dist/objects/images/Images.d.ts +11 -0
- package/dist/objects/images/Images.js +172 -55
- package/dist/objects/images/atlas-packer.d.ts +92 -91
- package/dist/objects/images/atlas-packer.js +230 -258
- package/dist/objects/images/pack-sources.d.ts +190 -0
- package/dist/objects/images/pack-sources.js +272 -0
- package/dist/objects/images/texture-budget.js +2 -2
- package/dist/objects/images/worker/atlas-worker.d.ts +56 -0
- package/dist/objects/images/worker/atlas-worker.js +123 -0
- package/dist/objects/images/worker/client.d.ts +62 -0
- package/dist/objects/images/worker/client.js +249 -0
- package/dist/objects/images/worker/index.d.ts +10 -0
- package/dist/objects/images/worker/index.js +1 -0
- package/dist/objects/materials/TextureMaterial.js +11 -1
- package/dist/objects/materials/effects/color-dim.js +17 -6
- package/dist/types/defs.d.ts +16 -2
- package/package.json +2 -2
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
|
|
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
|
|
56
|
-
* its fraction of the image
|
|
57
|
-
* tile's size
|
|
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
|
|
60
|
-
*
|
|
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,
|
|
63
|
-
const {
|
|
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,
|
|
46
|
-
// comparison
|
|
47
|
-
// `update(layer)
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
const [
|
|
53
|
-
if (!sameSet(
|
|
54
|
-
|
|
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.
|
|
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
|
|
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.
|
|
92
|
-
//
|
|
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.
|
|
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
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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
|
|
147
|
-
|
|
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
|
|
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
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
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
|
|
160
|
-
* @param
|
|
161
|
-
*
|
|
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,
|
|
238
|
+
function resizeTexture(texture, full, scaledRef, factor) {
|
|
239
|
+
const scaled = scaledRef.current;
|
|
164
240
|
if (factor >= 1) {
|
|
165
|
-
// Point back at the full
|
|
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
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
const context =
|
|
179
|
-
//
|
|
180
|
-
// still draw
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
194
|
-
* image shimmers under minification. The cost is that neighbors can bleed through
|
|
195
|
-
* levels, where the packer's one-pixel gutter no longer separates them.
|
|
196
|
-
* @param
|
|
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(
|
|
200
|
-
const texture = new CanvasTexture(
|
|
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
|
|
205
|
-
// down the atlas
|
|
206
|
-
// axis in the chain then points the same way
|
|
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 };
|