@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.
- package/README.md +34 -0
- package/dist/controls/CameraControls.js +10 -1
- 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
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A source that the packer can draw from without decoding it further. It is already decoded, and
|
|
3
|
+
* it reports its own pixel size. A `Blob` is the one image source that meets neither condition, so
|
|
4
|
+
* code decodes each `Blob` into one of these types before it reaches this module.
|
|
5
|
+
*/
|
|
6
|
+
export type DecodedSource = ImageBitmap | HTMLCanvasElement | OffscreenCanvas;
|
|
7
|
+
/**
|
|
8
|
+
* A canvas that one atlas is drawn onto. This type includes `OffscreenCanvas`, so packing can
|
|
9
|
+
* later move into a worker.
|
|
10
|
+
*/
|
|
11
|
+
export type AtlasCanvas = HTMLCanvasElement | OffscreenCanvas;
|
|
12
|
+
/**
|
|
13
|
+
* Creates the backing canvas for one atlas, sized to that atlas exactly.
|
|
14
|
+
* @param width - The atlas width in pixels.
|
|
15
|
+
* @param height - The atlas height in pixels.
|
|
16
|
+
* @returns The canvas to draw on.
|
|
17
|
+
*/
|
|
18
|
+
export type CreateAtlasCanvas = (width: number, height: number) => AtlasCanvas;
|
|
19
|
+
/**
|
|
20
|
+
* A region of a source image, in **source pixels with a top-left origin**. These are the same axes
|
|
21
|
+
* as `ImageDef.bounds`, and they are the exact `sx, sy, sw, sh` values that the packer passes to
|
|
22
|
+
* `drawImage`. This type keeps pixels instead of normalized values, so code cannot mistake it for
|
|
23
|
+
* a {@link UvRect}, whose origin is the opposite corner.
|
|
24
|
+
*/
|
|
25
|
+
export interface SourceRect {
|
|
26
|
+
/** Distance from the source's left edge, in pixels. */
|
|
27
|
+
x: number;
|
|
28
|
+
/** Distance from the source's top edge, in pixels. */
|
|
29
|
+
y: number;
|
|
30
|
+
/** Region width in pixels. */
|
|
31
|
+
width: number;
|
|
32
|
+
/** Region height in pixels. */
|
|
33
|
+
height: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A region of an atlas, **normalized in the canvas's own axes: a top-left origin, with v running
|
|
37
|
+
* down**.
|
|
38
|
+
*
|
|
39
|
+
* A texture with `flipY = false` consumes these values. That setting maps canvas row 0 to `v = 0`,
|
|
40
|
+
* so these numbers feed a uv transform directly, with no vertical inversion. This is deliberate.
|
|
41
|
+
* The scene already uses a y axis that points down, because it follows SVG coordinates and the
|
|
42
|
+
* camera matches them. A uv space that also runs down keeps every axis in the pipeline pointing
|
|
43
|
+
* the same way. If only one end inverts, images come out upside down.
|
|
44
|
+
*/
|
|
45
|
+
export interface UvRect {
|
|
46
|
+
/** Left edge, `0` at the atlas's left. */
|
|
47
|
+
u0: number;
|
|
48
|
+
/** Top edge, `0` at the atlas's top. */
|
|
49
|
+
v0: number;
|
|
50
|
+
/** Right edge. */
|
|
51
|
+
u1: number;
|
|
52
|
+
/** Bottom edge. */
|
|
53
|
+
v1: number;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* One source, or one tile of a split source, placed on an atlas. Each placement carries an array
|
|
57
|
+
* index instead of the source object as its address, because this data crosses a `postMessage`
|
|
58
|
+
* boundary, and object identity does not survive that boundary.
|
|
59
|
+
*/
|
|
60
|
+
export interface Placement {
|
|
61
|
+
/** Index into the `sources` array that the caller gave {@link packSources}. */
|
|
62
|
+
index: number;
|
|
63
|
+
/** Where the pixels sit on the atlas. */
|
|
64
|
+
uv: UvRect;
|
|
65
|
+
/**
|
|
66
|
+
* Which part of the source this placement holds. This is undefined when the source packs
|
|
67
|
+
* whole, which is the common case. It is set only on the tiles of a split source.
|
|
68
|
+
*/
|
|
69
|
+
source?: SourceRect;
|
|
70
|
+
}
|
|
71
|
+
/** One packed atlas, before its placements are mapped back onto the defs that wanted them. */
|
|
72
|
+
export interface SourceAtlas {
|
|
73
|
+
/** The canvas holding the packed pixels. Its `width`/`height` are the atlas's size. */
|
|
74
|
+
canvas: AtlasCanvas;
|
|
75
|
+
/** Every placement on this atlas. */
|
|
76
|
+
placements: Placement[];
|
|
77
|
+
/**
|
|
78
|
+
* Whether this atlas's pixels can shrink to fit a memory budget. Bitmaps pack into resizable
|
|
79
|
+
* atlases, canvases pack into fixed ones, and an atlas never mixes the two.
|
|
80
|
+
*/
|
|
81
|
+
resizable: boolean;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* What one source contributed to a pack. The caller holds the defs, so only the caller can warn
|
|
85
|
+
* about them. This report gives the caller the measurements it needs to do that.
|
|
86
|
+
*/
|
|
87
|
+
export interface SourceReport {
|
|
88
|
+
/** The source's intrinsic width in pixels. */
|
|
89
|
+
width: number;
|
|
90
|
+
/** The source's intrinsic height in pixels. */
|
|
91
|
+
height: number;
|
|
92
|
+
/**
|
|
93
|
+
* How many rects this source packed into. It is `0` when the source had no pixels and code
|
|
94
|
+
* dropped it. It is `1` when the source packed whole. Otherwise it is the tile count, for a
|
|
95
|
+
* source too big for one atlas that code had to cut.
|
|
96
|
+
*/
|
|
97
|
+
tiles: number;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Whether a source is a `Blob`, which is also true of a `File`.
|
|
101
|
+
*
|
|
102
|
+
* The test looks for `slice`, and it must keep looking for `slice`. `slice` is Chrome 21, Chrome
|
|
103
|
+
* Android 25, Safari 7 and iOS Safari 7, so every build target has it. `arrayBuffer` reads better
|
|
104
|
+
* and is wrong: it is Chrome 76 and Chrome Android 76, above this package's `chrome66` floor, so on
|
|
105
|
+
* the old Android WebViews it is `undefined` and every real `Blob` would fail this test and reach
|
|
106
|
+
* the packer undecoded (`packages/floorplan/src/components/Maplibre/utils/pmtiles-range-fallback.ts`
|
|
107
|
+
* works around the same floor). No other image source carries either method: not an `<img>`, a
|
|
108
|
+
* canvas, an offscreen canvas or a bitmap.
|
|
109
|
+
*
|
|
110
|
+
* A property test rather than `instanceof`, so this never reaches for a global that can be absent.
|
|
111
|
+
*
|
|
112
|
+
* This is the one home of the test. It decides both what needs a decode and what may cross to the
|
|
113
|
+
* atlas worker, so a second copy could drift from this one, and the drift would only show on the
|
|
114
|
+
* oldest targets.
|
|
115
|
+
* @param source - The source to classify.
|
|
116
|
+
* @returns Whether the source needs a decode.
|
|
117
|
+
*/
|
|
118
|
+
export declare function isBlob(source: object): source is Blob;
|
|
119
|
+
/**
|
|
120
|
+
* Whether a source's pixels can shrink to fit a memory budget. This is `true` for decoded images
|
|
121
|
+
* and bitmaps. It is `false` for canvases, because a canvas's pixels belong to the caller. The
|
|
122
|
+
* code counts a canvas at full size and never compresses it (see the scene's `memoryLimit`).
|
|
123
|
+
*
|
|
124
|
+
* The check looks for the absence of `getContext`. `HTMLCanvasElement` and `OffscreenCanvas` both
|
|
125
|
+
* have it. `ImageBitmap` and `Blob` do not. This is a property test, not an
|
|
126
|
+
* `instanceof` check, because the SDK runs in WebViews. There, `OffscreenCanvas` and `ImageBitmap`
|
|
127
|
+
* can be missing as globals, and an `instanceof` check against a missing global throws an error.
|
|
128
|
+
* @param source - The source to classify.
|
|
129
|
+
* @returns Whether the source can shrink.
|
|
130
|
+
*/
|
|
131
|
+
export declare function isResizableSource(source: object): boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Makes the canvas that one atlas draws onto. The function returns an `OffscreenCanvas` where one
|
|
134
|
+
* exists, and a DOM canvas otherwise.
|
|
135
|
+
*
|
|
136
|
+
* This is the only place in the package that builds an atlas canvas, so the `OffscreenCanvas`
|
|
137
|
+
* guard lives here alone. The guard uses this plain form because `eslint-plugin-compat`
|
|
138
|
+
* understands it and flags only an unguarded construction. Nothing here needs hiding from the
|
|
139
|
+
* linter.
|
|
140
|
+
* @param width - The canvas width in pixels.
|
|
141
|
+
* @param height - The canvas height in pixels.
|
|
142
|
+
* @returns The canvas.
|
|
143
|
+
*/
|
|
144
|
+
export declare function createAtlasCanvas(width: number, height: number): AtlasCanvas;
|
|
145
|
+
/**
|
|
146
|
+
* Packs decoded sources into atlas canvases and reports where each one landed.
|
|
147
|
+
*
|
|
148
|
+
* The code splits sources by kind first: resizable ones (bitmaps and images) apart from fixed ones
|
|
149
|
+
* (canvases). Each kind packs on its own, so no atlas ever mixes the two kinds. This lets a memory
|
|
150
|
+
* budget scale a whole resizable atlas without touching a canvas's pixels. Within one kind, the
|
|
151
|
+
* code offers each source to the packer whole. Anything too big for an atlas comes back parked in
|
|
152
|
+
* a bin of its own natural size, and the packer flags that bin. Those are the only sources that get
|
|
153
|
+
* cut into tiles, and the tiles go back through the same packer. Letting the packer decide keeps
|
|
154
|
+
* the guarantee that no atlas exceeds `maxDimension`, with no need to measure every image up
|
|
155
|
+
* front.
|
|
156
|
+
*
|
|
157
|
+
* This function does **not** deduplicate sources. The caller must collect unique sources before it
|
|
158
|
+
* decodes them, so every entry in `sources` is already distinct.
|
|
159
|
+
*
|
|
160
|
+
* This module runs unchanged on the main thread and inside the atlas worker. It must not use
|
|
161
|
+
* `document`, the def types, or the logger.
|
|
162
|
+
* @param sources - The decoded sources to pack, in the order the caller gives them.
|
|
163
|
+
* @param maxDimension - The largest atlas edge in pixels. The function floors this to a positive
|
|
164
|
+
* integer.
|
|
165
|
+
* @param createCanvas - Makes each atlas canvas. Defaults to {@link createAtlasCanvas}.
|
|
166
|
+
* @returns The atlases in packing order (resizable first, then fixed), and one report for each
|
|
167
|
+
* source, aligned by index with `sources`.
|
|
168
|
+
*/
|
|
169
|
+
export declare function packSources(sources: readonly (DecodedSource | null)[], maxDimension: number, createCanvas?: CreateAtlasCanvas): {
|
|
170
|
+
atlases: SourceAtlas[];
|
|
171
|
+
reports: SourceReport[];
|
|
172
|
+
};
|
|
173
|
+
/**
|
|
174
|
+
* Resolves an atlas canvas's 2D context, asking one canvas kind at a time.
|
|
175
|
+
*
|
|
176
|
+
* Deliberately not `canvas.getContext('2d')` on the union. The two kinds' overload lists disagree
|
|
177
|
+
* at the end — `HTMLCanvasElement` closes with `getContext(id: string): RenderingContext | null`,
|
|
178
|
+
* `OffscreenCanvas` with an `OffscreenRenderingContextId` one — and resolving across the union can
|
|
179
|
+
* combine one kind's `"2d"` overload with the other's catch-all, yielding
|
|
180
|
+
* `OffscreenCanvasRenderingContext2D | RenderingContext`: a type covering bitmap and WebGL contexts
|
|
181
|
+
* that have no `drawImage`. It surfaces only on incremental rebuilds (any compile error in this
|
|
182
|
+
* file leaves the bad resolution cached in `.tsbuildinfo`, where it outlives the error that caused
|
|
183
|
+
* it), so a clean build hides it and a working tree quietly breaks. Narrowed first, each branch has
|
|
184
|
+
* one concrete type whose `"2d"` overload is the only candidate, and the result is stable either
|
|
185
|
+
* way.
|
|
186
|
+
* @param canvas - The atlas canvas.
|
|
187
|
+
* @returns Its 2D context, or `null` when the canvas cannot provide one.
|
|
188
|
+
*/
|
|
189
|
+
export declare function contextOf(canvas: AtlasCanvas): CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null;
|
|
190
|
+
//# sourceMappingURL=pack-sources.d.ts.map
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { MaxRectsPacker } from 'maxrects-packer';
|
|
2
|
+
/**
|
|
3
|
+
* Whether a source is a `Blob`, which is also true of a `File`.
|
|
4
|
+
*
|
|
5
|
+
* The test looks for `slice`, and it must keep looking for `slice`. `slice` is Chrome 21, Chrome
|
|
6
|
+
* Android 25, Safari 7 and iOS Safari 7, so every build target has it. `arrayBuffer` reads better
|
|
7
|
+
* and is wrong: it is Chrome 76 and Chrome Android 76, above this package's `chrome66` floor, so on
|
|
8
|
+
* the old Android WebViews it is `undefined` and every real `Blob` would fail this test and reach
|
|
9
|
+
* the packer undecoded (`packages/floorplan/src/components/Maplibre/utils/pmtiles-range-fallback.ts`
|
|
10
|
+
* works around the same floor). No other image source carries either method: not an `<img>`, a
|
|
11
|
+
* canvas, an offscreen canvas or a bitmap.
|
|
12
|
+
*
|
|
13
|
+
* A property test rather than `instanceof`, so this never reaches for a global that can be absent.
|
|
14
|
+
*
|
|
15
|
+
* This is the one home of the test. It decides both what needs a decode and what may cross to the
|
|
16
|
+
* atlas worker, so a second copy could drift from this one, and the drift would only show on the
|
|
17
|
+
* oldest targets.
|
|
18
|
+
* @param source - The source to classify.
|
|
19
|
+
* @returns Whether the source needs a decode.
|
|
20
|
+
*/
|
|
21
|
+
export function isBlob(source) {
|
|
22
|
+
return typeof source.slice === 'function';
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Whether a source's pixels can shrink to fit a memory budget. This is `true` for decoded images
|
|
26
|
+
* and bitmaps. It is `false` for canvases, because a canvas's pixels belong to the caller. The
|
|
27
|
+
* code counts a canvas at full size and never compresses it (see the scene's `memoryLimit`).
|
|
28
|
+
*
|
|
29
|
+
* The check looks for the absence of `getContext`. `HTMLCanvasElement` and `OffscreenCanvas` both
|
|
30
|
+
* have it. `ImageBitmap` and `Blob` do not. This is a property test, not an
|
|
31
|
+
* `instanceof` check, because the SDK runs in WebViews. There, `OffscreenCanvas` and `ImageBitmap`
|
|
32
|
+
* can be missing as globals, and an `instanceof` check against a missing global throws an error.
|
|
33
|
+
* @param source - The source to classify.
|
|
34
|
+
* @returns Whether the source can shrink.
|
|
35
|
+
*/
|
|
36
|
+
export function isResizableSource(source) {
|
|
37
|
+
return !('getContext' in source);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Makes the canvas that one atlas draws onto. The function returns an `OffscreenCanvas` where one
|
|
41
|
+
* exists, and a DOM canvas otherwise.
|
|
42
|
+
*
|
|
43
|
+
* This is the only place in the package that builds an atlas canvas, so the `OffscreenCanvas`
|
|
44
|
+
* guard lives here alone. The guard uses this plain form because `eslint-plugin-compat`
|
|
45
|
+
* understands it and flags only an unguarded construction. Nothing here needs hiding from the
|
|
46
|
+
* linter.
|
|
47
|
+
* @param width - The canvas width in pixels.
|
|
48
|
+
* @param height - The canvas height in pixels.
|
|
49
|
+
* @returns The canvas.
|
|
50
|
+
*/
|
|
51
|
+
export function createAtlasCanvas(width, height) {
|
|
52
|
+
if (typeof OffscreenCanvas !== 'undefined')
|
|
53
|
+
return new OffscreenCanvas(width, height);
|
|
54
|
+
const canvas = document.createElement('canvas');
|
|
55
|
+
canvas.width = width;
|
|
56
|
+
canvas.height = height;
|
|
57
|
+
return canvas;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Packs decoded sources into atlas canvases and reports where each one landed.
|
|
61
|
+
*
|
|
62
|
+
* The code splits sources by kind first: resizable ones (bitmaps and images) apart from fixed ones
|
|
63
|
+
* (canvases). Each kind packs on its own, so no atlas ever mixes the two kinds. This lets a memory
|
|
64
|
+
* budget scale a whole resizable atlas without touching a canvas's pixels. Within one kind, the
|
|
65
|
+
* code offers each source to the packer whole. Anything too big for an atlas comes back parked in
|
|
66
|
+
* a bin of its own natural size, and the packer flags that bin. Those are the only sources that get
|
|
67
|
+
* cut into tiles, and the tiles go back through the same packer. Letting the packer decide keeps
|
|
68
|
+
* the guarantee that no atlas exceeds `maxDimension`, with no need to measure every image up
|
|
69
|
+
* front.
|
|
70
|
+
*
|
|
71
|
+
* This function does **not** deduplicate sources. The caller must collect unique sources before it
|
|
72
|
+
* decodes them, so every entry in `sources` is already distinct.
|
|
73
|
+
*
|
|
74
|
+
* This module runs unchanged on the main thread and inside the atlas worker. It must not use
|
|
75
|
+
* `document`, the def types, or the logger.
|
|
76
|
+
* @param sources - The decoded sources to pack, in the order the caller gives them.
|
|
77
|
+
* @param maxDimension - The largest atlas edge in pixels. The function floors this to a positive
|
|
78
|
+
* integer.
|
|
79
|
+
* @param createCanvas - Makes each atlas canvas. Defaults to {@link createAtlasCanvas}.
|
|
80
|
+
* @returns The atlases in packing order (resizable first, then fixed), and one report for each
|
|
81
|
+
* source, aligned by index with `sources`.
|
|
82
|
+
*/
|
|
83
|
+
export function packSources(sources, maxDimension, createCanvas = createAtlasCanvas) {
|
|
84
|
+
const limit = Math.max(1, Math.floor(maxDimension));
|
|
85
|
+
// Every member of `DecodedSource` reports its own pixel size, so nothing here needs to duck-type
|
|
86
|
+
// a size. That was only ever needed because an `<img>` reports its layout size as `width`. A
|
|
87
|
+
// source that fails to decode arrives as `null` and reports `0 x 0`. Code skips that source the
|
|
88
|
+
// same way it skips a zero-sized canvas.
|
|
89
|
+
const reports = sources.map((source) => source
|
|
90
|
+
? { width: source.width, height: source.height, tiles: 0 }
|
|
91
|
+
: { width: 0, height: 0, tiles: 0 });
|
|
92
|
+
const resizable = [];
|
|
93
|
+
const fixed = [];
|
|
94
|
+
for (let index = 0; index < sources.length; index++) {
|
|
95
|
+
const source = sources[index];
|
|
96
|
+
// A source that failed to decode is dropped later by the size check in `packGroup`. Code
|
|
97
|
+
// groups it with the resizable sources, so it never lands in an atlas of canvases on its way
|
|
98
|
+
// to being skipped.
|
|
99
|
+
(!source || isResizableSource(source) ? resizable : fixed).push(index);
|
|
100
|
+
}
|
|
101
|
+
// One packer serves both groups, reset between them. The configuration (size, padding, options)
|
|
102
|
+
// is the same for each, so reusing the packer spares an allocation per pack. The packer cannot
|
|
103
|
+
// carry over across pack calls, though, because `limit` is fixed at construction and changes
|
|
104
|
+
// from call to call.
|
|
105
|
+
// `pot: false`, because WebGL 2 samples non-power-of-two textures without restriction. Rounding
|
|
106
|
+
// each atlas up only wastes memory. `smart` stays on (the default), which shrinks every bin to
|
|
107
|
+
// its content. That is why the code reads the size back off the bin instead of assuming
|
|
108
|
+
// `limit`.
|
|
109
|
+
const packer = new MaxRectsPacker(limit, limit, PADDING, { pot: false });
|
|
110
|
+
const atlases = [
|
|
111
|
+
...packGroup(packer, sources, resizable, reports, limit, createCanvas, true),
|
|
112
|
+
...packGroup(packer, sources, fixed, reports, limit, createCanvas, false),
|
|
113
|
+
];
|
|
114
|
+
return { atlases, reports };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The gap between neighboring images on an atlas. This gap stops linear filtering at one image's
|
|
118
|
+
* edge from reaching into the next image's pixels. The packer applies this gap *between* rects
|
|
119
|
+
* only, so atlas edges stay flush against the pixels. This is also why a tile exactly
|
|
120
|
+
* `maxDimension` across still fits in a bin.
|
|
121
|
+
*/
|
|
122
|
+
const PADDING = 1;
|
|
123
|
+
/**
|
|
124
|
+
* Packs one kind of source, either all resizable or all fixed, into atlases. The function tags
|
|
125
|
+
* each atlas with the group's kind and records each source's tile count in `reports`.
|
|
126
|
+
* @param packer - The shared packer. It is reset to empty before this group is added.
|
|
127
|
+
* @param sources - Every source, indexed by the entries in `indices`.
|
|
128
|
+
* @param indices - The indices of this kind. This list can be empty, and then nothing packs.
|
|
129
|
+
* @param reports - Per-source reports. This function fills in each report's `tiles` count.
|
|
130
|
+
* @param limit - The largest atlas edge, in pixels.
|
|
131
|
+
* @param createCanvas - Makes each atlas canvas.
|
|
132
|
+
* @param resizable - The kind, stamped onto every atlas that this call produces.
|
|
133
|
+
* @returns The atlases this group produced.
|
|
134
|
+
*/
|
|
135
|
+
function packGroup(packer, sources, indices, reports, limit, createCanvas, resizable) {
|
|
136
|
+
const rects = [];
|
|
137
|
+
for (const index of indices) {
|
|
138
|
+
const { width, height } = reports[index];
|
|
139
|
+
// A `drawImage` call throws in some browsers when the source region has zero width or zero
|
|
140
|
+
// height. So code drops an image that has not decoded, or a canvas sized to nothing, instead
|
|
141
|
+
// of drawing it. Its report keeps `tiles: 0`, and that value tells the caller to warn about
|
|
142
|
+
// it.
|
|
143
|
+
if (width <= 0 || height <= 0)
|
|
144
|
+
continue;
|
|
145
|
+
rects.push({ width, height, x: 0, y: 0, hash: `${index}`, data: { index } });
|
|
146
|
+
}
|
|
147
|
+
// Clear whatever the previous group left in the packer, keeping its configuration.
|
|
148
|
+
packer.reset();
|
|
149
|
+
// `addArray` sorts by longest edge before it places rects, which packs far tighter than
|
|
150
|
+
// insertion order.
|
|
151
|
+
packer.addArray(rects);
|
|
152
|
+
// The packer tags whatever it must park in an oversized bin. The packer decides what needs
|
|
153
|
+
// cutting, not a separate measurement in this code. The tiles are ordinary rects, and they pack
|
|
154
|
+
// into the bins that the first pass already opened.
|
|
155
|
+
const oversized = rects.filter((rect) => rect.oversized);
|
|
156
|
+
if (oversized.length > 0) {
|
|
157
|
+
packer.addArray(oversized.flatMap((rect) => cutTiles(rect, reports[rect.data.index], limit)));
|
|
158
|
+
}
|
|
159
|
+
// Every oversized bin now has its tiles standing in for it, so the code drops the bin instead of
|
|
160
|
+
// returning it. Handing back a canvas larger than the caller's limit is the exact problem this
|
|
161
|
+
// code exists to prevent.
|
|
162
|
+
const bins = packer.bins.filter((bin) => !bin.rects.some((rect) => rect.oversized));
|
|
163
|
+
const atlases = bins.map((bin) => ({
|
|
164
|
+
...renderAtlas(bin, sources, reports, createCanvas),
|
|
165
|
+
resizable,
|
|
166
|
+
}));
|
|
167
|
+
// Count what actually landed. This lets code tell a dropped source apart from a split one.
|
|
168
|
+
for (const bin of bins)
|
|
169
|
+
for (const rect of bin.rects)
|
|
170
|
+
reports[rect.data.index].tiles++;
|
|
171
|
+
return atlases;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Cuts a source that did not fit in the packer into a grid of tiles. No tile is larger than one
|
|
175
|
+
* atlas. Tiles are exact and do not overlap. Each tile ends up alone, or beside its own sibling
|
|
176
|
+
* tiles, in an atlas. Nothing can bleed across the cut, so the code needs no overlap column to
|
|
177
|
+
* hide a seam.
|
|
178
|
+
* @param rect - The oversized rect that names the source to cut.
|
|
179
|
+
* @param report - That source's measured size.
|
|
180
|
+
* @param limit - The largest tile edge, in pixels.
|
|
181
|
+
* @returns The tiles to pack in its place.
|
|
182
|
+
*/
|
|
183
|
+
function cutTiles(rect, report, limit) {
|
|
184
|
+
const columns = Math.ceil(report.width / limit);
|
|
185
|
+
const rows = Math.ceil(report.height / limit);
|
|
186
|
+
const tiles = [];
|
|
187
|
+
for (let row = 0; row < rows; row++) {
|
|
188
|
+
const y = row * limit;
|
|
189
|
+
const height = Math.min(limit, report.height - y);
|
|
190
|
+
for (let column = 0; column < columns; column++) {
|
|
191
|
+
const x = column * limit;
|
|
192
|
+
const width = Math.min(limit, report.width - x);
|
|
193
|
+
tiles.push({
|
|
194
|
+
width,
|
|
195
|
+
height,
|
|
196
|
+
x: 0,
|
|
197
|
+
y: 0,
|
|
198
|
+
hash: `${rect.hash}:${column},${row}`,
|
|
199
|
+
data: { index: rect.data.index, source: { x, y, width, height } },
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return tiles;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Draws one packed bin onto a canvas and records where each rect landed.
|
|
207
|
+
* @param bin - The packed bin, already sized to its content.
|
|
208
|
+
* @param sources - Every source, indexed by each rect's tile.
|
|
209
|
+
* @param reports - Per-source sizes, for the whole-image draw region.
|
|
210
|
+
* @param createCanvas - Makes the canvas to draw on.
|
|
211
|
+
* @returns The atlas, less its `resizable` flag.
|
|
212
|
+
*/
|
|
213
|
+
function renderAtlas(bin, sources, reports, createCanvas) {
|
|
214
|
+
const canvas = createCanvas(bin.width, bin.height);
|
|
215
|
+
const context = contextOf(canvas);
|
|
216
|
+
// Nothing downstream can recover from a missing context. Every atlas then uploads blank, and
|
|
217
|
+
// the images silently draw as nothing at all. It is better to fail here, where the cause stays
|
|
218
|
+
// visible.
|
|
219
|
+
if (!context)
|
|
220
|
+
throw new Error(`pack-sources: no 2d context for a ${bin.width}x${bin.height} atlas`);
|
|
221
|
+
const placements = [];
|
|
222
|
+
for (const rect of bin.rects) {
|
|
223
|
+
const { index, source } = rect.data;
|
|
224
|
+
// The copied region is the tile when the rect has one, and the whole image otherwise. Both
|
|
225
|
+
// read from the source's own axes. This region happens to match the packed rect's size today,
|
|
226
|
+
// because tiles are placed 1:1 and unrotated. Reading the size from the destination instead
|
|
227
|
+
// is correct only by accident, not by design. The code keeps this region apart from `source`
|
|
228
|
+
// itself, which stays undefined for an unsplit image, so the placement below does not report
|
|
229
|
+
// a region that the caller has no use for.
|
|
230
|
+
const { width, height } = reports[index];
|
|
231
|
+
const region = source ?? { x: 0, y: 0, width, height };
|
|
232
|
+
// This value is never null, by construction. A source that fails to decode reports `0 x 0`
|
|
233
|
+
// and never reaches the packer, so every rect in a bin has a drawable source behind it.
|
|
234
|
+
const decoded = sources[index];
|
|
235
|
+
if (!decoded)
|
|
236
|
+
continue;
|
|
237
|
+
context.drawImage(decoded, region.x, region.y, region.width, region.height, rect.x, rect.y, rect.width, rect.height);
|
|
238
|
+
// The rect uses canvas pixels, and uv keeps the canvas's axes, so this is a plain
|
|
239
|
+
// normalization with no vertical inversion (see {@link UvRect}).
|
|
240
|
+
const uv = {
|
|
241
|
+
u0: rect.x / bin.width,
|
|
242
|
+
v0: rect.y / bin.height,
|
|
243
|
+
u1: (rect.x + rect.width) / bin.width,
|
|
244
|
+
v1: (rect.y + rect.height) / bin.height,
|
|
245
|
+
};
|
|
246
|
+
placements.push(source ? { index, uv, source } : { index, uv });
|
|
247
|
+
}
|
|
248
|
+
return { canvas, placements };
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Resolves an atlas canvas's 2D context, asking one canvas kind at a time.
|
|
252
|
+
*
|
|
253
|
+
* Deliberately not `canvas.getContext('2d')` on the union. The two kinds' overload lists disagree
|
|
254
|
+
* at the end — `HTMLCanvasElement` closes with `getContext(id: string): RenderingContext | null`,
|
|
255
|
+
* `OffscreenCanvas` with an `OffscreenRenderingContextId` one — and resolving across the union can
|
|
256
|
+
* combine one kind's `"2d"` overload with the other's catch-all, yielding
|
|
257
|
+
* `OffscreenCanvasRenderingContext2D | RenderingContext`: a type covering bitmap and WebGL contexts
|
|
258
|
+
* that have no `drawImage`. It surfaces only on incremental rebuilds (any compile error in this
|
|
259
|
+
* file leaves the bad resolution cached in `.tsbuildinfo`, where it outlives the error that caused
|
|
260
|
+
* it), so a clean build hides it and a working tree quietly breaks. Narrowed first, each branch has
|
|
261
|
+
* one concrete type whose `"2d"` overload is the only candidate, and the result is stable either
|
|
262
|
+
* way.
|
|
263
|
+
* @param canvas - The atlas canvas.
|
|
264
|
+
* @returns Its 2D context, or `null` when the canvas cannot provide one.
|
|
265
|
+
*/
|
|
266
|
+
export function contextOf(canvas) {
|
|
267
|
+
// `in` rather than `instanceof`: in a worker there is no `HTMLCanvasElement` global to test
|
|
268
|
+
// against, and an `instanceof` against a missing global throws.
|
|
269
|
+
if ('convertToBlob' in canvas)
|
|
270
|
+
return canvas.getContext('2d');
|
|
271
|
+
return canvas.getContext('2d');
|
|
272
|
+
}
|
|
@@ -203,8 +203,8 @@ export function useResizeFactor(budget, atlas) {
|
|
|
203
203
|
if (!active)
|
|
204
204
|
return;
|
|
205
205
|
const entry = {
|
|
206
|
-
width: atlas.
|
|
207
|
-
height: atlas.
|
|
206
|
+
width: atlas.image.width,
|
|
207
|
+
height: atlas.image.height,
|
|
208
208
|
resizable: atlas.resizable,
|
|
209
209
|
};
|
|
210
210
|
budget.register(entry);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type Placement, type SourceReport } from '../pack-sources';
|
|
2
|
+
/**
|
|
3
|
+
* The one source the worker takes: a `Blob`.
|
|
4
|
+
*
|
|
5
|
+
* The worker could draw a bitmap or an offscreen canvas too, but it never receives one. A `Blob` is
|
|
6
|
+
* the only source that crosses by copy. Its handle clones and the bytes stay put, so the def that
|
|
7
|
+
* owns the source keeps it. Any other source would have to move, a moved source is dead on the main
|
|
8
|
+
* thread, and the next repack of that layer would then find nothing to draw. Tier selection keeps
|
|
9
|
+
* those sources on the main thread, where they pack inline.
|
|
10
|
+
*/
|
|
11
|
+
export type WorkerSource = Blob;
|
|
12
|
+
/**
|
|
13
|
+
* What the main thread sends.
|
|
14
|
+
*
|
|
15
|
+
* Every message carries the `id` of the pack it belongs to. One worker serves every pack, so two
|
|
16
|
+
* layers can be in flight at once and the worker must keep their sources apart.
|
|
17
|
+
*/
|
|
18
|
+
export type AtlasWorkerRequest =
|
|
19
|
+
/** One batch of sources, in the order they were collected. Sent as many times as needed. */
|
|
20
|
+
{
|
|
21
|
+
type: 'sources';
|
|
22
|
+
id: number;
|
|
23
|
+
sources: WorkerSource[];
|
|
24
|
+
}
|
|
25
|
+
/** No more batches: decode what has arrived, pack it, and reply. */
|
|
26
|
+
| {
|
|
27
|
+
type: 'pack';
|
|
28
|
+
id: number;
|
|
29
|
+
maxDimension: number;
|
|
30
|
+
}
|
|
31
|
+
/** Nobody wants this pack any more. Drop it and reply nothing. */
|
|
32
|
+
| {
|
|
33
|
+
type: 'cancel';
|
|
34
|
+
id: number;
|
|
35
|
+
};
|
|
36
|
+
/** One atlas the worker packed. The bitmap is transferred out, not cloned. */
|
|
37
|
+
export interface WorkerAtlas {
|
|
38
|
+
/** The atlas's pixels, moved out of the offscreen canvas they were drawn into. */
|
|
39
|
+
bitmap: ImageBitmap;
|
|
40
|
+
/** Every placement on this atlas, addressing sources by their index in the batches. */
|
|
41
|
+
placements: Placement[];
|
|
42
|
+
/** Whether these pixels may be downscaled to fit a memory budget. */
|
|
43
|
+
resizable: boolean;
|
|
44
|
+
}
|
|
45
|
+
/** What the worker sends back — at most one of these per pack, carrying that pack's `id`. */
|
|
46
|
+
export type AtlasWorkerResponse = {
|
|
47
|
+
type: 'packed';
|
|
48
|
+
id: number;
|
|
49
|
+
atlases: WorkerAtlas[];
|
|
50
|
+
reports: SourceReport[];
|
|
51
|
+
} | {
|
|
52
|
+
type: 'failed';
|
|
53
|
+
id: number;
|
|
54
|
+
message: string;
|
|
55
|
+
};
|
|
56
|
+
//# sourceMappingURL=atlas-worker.d.ts.map
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { packSources, } from '../pack-sources';
|
|
2
|
+
/**
|
|
3
|
+
* Waits for every batch to decode, packs them, and moves each atlas's pixels out of its canvas.
|
|
4
|
+
* @param id - Which pack to run.
|
|
5
|
+
* @param maxDimension - Largest atlas edge in pixels.
|
|
6
|
+
* @returns The reply to post back, or null when the pack was canceled and nothing is owed.
|
|
7
|
+
*/
|
|
8
|
+
async function pack(id, maxDimension) {
|
|
9
|
+
// `requestFor` and not `requests.get`, because a layer with no sources sends its closing message
|
|
10
|
+
// and nothing else. That is an empty pack, and it still owes a reply: without one the layer
|
|
11
|
+
// waits forever and draws nothing. The only other way to reach this with no state is a cancel
|
|
12
|
+
// that arrived first, and that happens only when the closing message was never sent, so the
|
|
13
|
+
// empty reply it produces goes to a caller that has already forgotten the id and is discarded.
|
|
14
|
+
const request = requestFor(id);
|
|
15
|
+
request.packing = true;
|
|
16
|
+
const decoded = await Promise.all(request.decoding);
|
|
17
|
+
requests.delete(id);
|
|
18
|
+
// The only yield point in a pack. `packSources` runs straight through once it starts, so this is
|
|
19
|
+
// the last moment a cancel can save the worker's time for the next pack.
|
|
20
|
+
if (request.canceled) {
|
|
21
|
+
closeDecoded(decoded);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
// `createAtlasCanvas` returns an `OffscreenCanvas` here and never takes its fallback branch. A
|
|
25
|
+
// worker runs at all only because `selectTier` found `OffscreenCanvas` on the main thread, and
|
|
26
|
+
// the constructor is exposed to both globals or to neither. No fallback is worth writing: a
|
|
27
|
+
// worker has no `document`, so without `OffscreenCanvas` it could build no canvas at all.
|
|
28
|
+
const { atlases, reports } = packSources(decoded, maxDimension);
|
|
29
|
+
// `transferToImageBitmap` moves the canvas's backing bitmap out instead of copying it, which is
|
|
30
|
+
// what makes an atlas cheap to return. The canvas is left blank, and this code drops it anyway.
|
|
31
|
+
const packed = atlases.map((atlas) => ({
|
|
32
|
+
bitmap: atlas.canvas.transferToImageBitmap(),
|
|
33
|
+
placements: atlas.placements,
|
|
34
|
+
resizable: atlas.resizable,
|
|
35
|
+
}));
|
|
36
|
+
// The atlases hold the pixels now, so every decoded bitmap is dead. This worker decoded all of
|
|
37
|
+
// them and owns all of them. Close them rather than leaving a thousand to the collector.
|
|
38
|
+
closeDecoded(decoded);
|
|
39
|
+
return { type: 'packed', id, atlases: packed, reports };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Abandons one pack.
|
|
43
|
+
*
|
|
44
|
+
* A pack that has not reached {@link pack} yet is dropped here, because nothing else will ever read
|
|
45
|
+
* its decodes. A pack that is already running is only marked: it reads the mark at its one yield
|
|
46
|
+
* point and cleans up there.
|
|
47
|
+
* @param id - Which pack to abandon.
|
|
48
|
+
*/
|
|
49
|
+
function cancel(id) {
|
|
50
|
+
const request = requests.get(id);
|
|
51
|
+
// Already replied, or never started. Either way this worker holds nothing for that pack.
|
|
52
|
+
if (!request)
|
|
53
|
+
return;
|
|
54
|
+
request.canceled = true;
|
|
55
|
+
if (request.packing)
|
|
56
|
+
return;
|
|
57
|
+
requests.delete(id);
|
|
58
|
+
void Promise.all(request.decoding).then(closeDecoded);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Frees decoded pixels this worker owns.
|
|
62
|
+
*
|
|
63
|
+
* An `ImageBitmap` holds its pixels outside the JavaScript heap, where collection does not reclaim
|
|
64
|
+
* them promptly. A canceled pack of a thousand logos would otherwise sit on a thousand of them.
|
|
65
|
+
* @param decoded - What the sources decoded into.
|
|
66
|
+
*/
|
|
67
|
+
function closeDecoded(decoded) {
|
|
68
|
+
for (const source of decoded)
|
|
69
|
+
if (source && 'close' in source)
|
|
70
|
+
source.close();
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Finds a pack's state, starting it on the first batch that arrives.
|
|
74
|
+
* @param id - Which pack the batch belongs to.
|
|
75
|
+
* @returns That pack's state.
|
|
76
|
+
*/
|
|
77
|
+
function requestFor(id) {
|
|
78
|
+
let request = requests.get(id);
|
|
79
|
+
if (!request) {
|
|
80
|
+
request = { decoding: [], canceled: false, packing: false };
|
|
81
|
+
requests.set(id, request);
|
|
82
|
+
}
|
|
83
|
+
return request;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Every pack this worker is working on, by request id.
|
|
87
|
+
*
|
|
88
|
+
* The main thread does not terminate this worker once it has returned an atlas, because those
|
|
89
|
+
* atlases are backed by GPU memory the worker owns. It stops a worker only when that worker broke
|
|
90
|
+
* before it produced anything. So one worker serves every pack of every layer, for the life of the
|
|
91
|
+
* page, and each pack's state has to be kept apart from the others'. An entry is deleted as soon
|
|
92
|
+
* as its pack replies or is canceled.
|
|
93
|
+
*/
|
|
94
|
+
const requests = new Map();
|
|
95
|
+
/** This worker's global scope. */
|
|
96
|
+
const scope = self;
|
|
97
|
+
scope.onmessage = (event) => {
|
|
98
|
+
const message = event.data;
|
|
99
|
+
if (message.type === 'sources') {
|
|
100
|
+
const request = requestFor(message.id);
|
|
101
|
+
// Null rather than a rejection, so one set of bytes that is not an image drops its own def
|
|
102
|
+
// instead of failing the whole layer's pack. `packSources` skips it as a source with no pixels.
|
|
103
|
+
for (const source of message.sources) {
|
|
104
|
+
request.decoding.push(createImageBitmap(source).catch(() => null));
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (message.type === 'cancel') {
|
|
109
|
+
cancel(message.id);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const { id } = message;
|
|
113
|
+
pack(id, message.maxDimension).then((response) => {
|
|
114
|
+
// Nothing is owed for a canceled pack. The main thread has already settled it.
|
|
115
|
+
if (!response)
|
|
116
|
+
return;
|
|
117
|
+
// The bitmaps move rather than copy. After this the worker holds nothing for this pack.
|
|
118
|
+
scope.postMessage(response, response.atlases.map((atlas) => atlas.bitmap));
|
|
119
|
+
}, (error) => {
|
|
120
|
+
requests.delete(id);
|
|
121
|
+
scope.postMessage({ type: 'failed', id, message: String(error) });
|
|
122
|
+
});
|
|
123
|
+
};
|