@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.
- package/README.md +34 -0
- package/dist/controls/camera-api.d.ts +28 -0
- package/dist/controls/camera-api.js +33 -10
- package/dist/controls/hooks.js +2 -0
- package/dist/core/InstanceUniforms.d.ts +15 -0
- package/dist/core/InstanceUniforms.js +32 -0
- package/dist/core/batch/BatchedMesh.js +6 -0
- package/dist/core/instanced/instanced-material.js +5 -1
- package/dist/core/lines/BatchedLines.d.ts +9 -7
- package/dist/core/lines/BatchedLines.js +9 -7
- package/dist/core/lines/{FrustumCulling.d.ts → FrustumCullingLines.d.ts} +7 -6
- package/dist/core/lines/{FrustumCulling.js → FrustumCullingLines.js} +2 -2
- package/dist/core/lines/{Raycasting.d.ts → RaycastingLines.d.ts} +7 -6
- package/dist/core/lines/{Raycasting.js → RaycastingLines.js} +2 -2
- package/dist/core/lines/lines-material.js +10 -1
- package/dist/core/sprites/BatchedSprites.d.ts +115 -0
- package/dist/core/sprites/BatchedSprites.js +205 -0
- package/dist/core/sprites/RaycastingSprites.d.ts +41 -0
- package/dist/core/sprites/RaycastingSprites.js +71 -0
- package/dist/core/sprites/sprite-material.d.ts +59 -0
- package/dist/core/sprites/sprite-material.js +192 -0
- package/dist/core/text/BatchedText.d.ts +6 -0
- package/dist/core/text/BatchedText.js +6 -0
- package/dist/core/text/MemberTexture.js +55 -1
- package/dist/core/text/Text.d.ts +16 -1
- package/dist/core/text/Text.js +15 -0
- package/dist/core/text/batched-text-material.js +4 -2
- package/dist/objects/containers/hooks.d.ts +7 -2
- package/dist/objects/containers/hooks.js +20 -5
- package/dist/objects/containers/mesh-writes.js +3 -3
- package/dist/objects/images/ImageInstance.js +111 -43
- package/dist/objects/images/Images.d.ts +19 -7
- package/dist/objects/images/Images.js +266 -83
- package/dist/objects/images/atlas-packer.d.ts +92 -91
- package/dist/objects/images/atlas-packer.js +230 -258
- package/dist/objects/images/image-container.d.ts +14 -3
- package/dist/objects/images/image-container.js +19 -5
- 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/layer/Layer.d.ts +7 -6
- package/dist/objects/layer/Layer.js +40 -30
- package/dist/objects/layer/layer-context.d.ts +41 -0
- package/dist/objects/layer/layer-context.js +45 -0
- package/dist/objects/materials/ColorMaterial.js +16 -3
- package/dist/objects/materials/LineMaterial.js +4 -2
- package/dist/objects/materials/TextureMaterial.js +14 -2
- package/dist/objects/materials/effects/color-dim.d.ts +1 -10
- package/dist/objects/materials/effects/color-dim.js +21 -18
- package/dist/objects/materials/effects/effects-context.d.ts +6 -5
- package/dist/objects/materials/shared.d.ts +18 -0
- package/dist/objects/materials/shared.js +21 -0
- package/dist/objects/models/Models.js +1 -8
- package/dist/objects/shapes/Shapes.js +2 -9
- package/dist/objects/text/TextInstance.js +1 -1
- package/dist/objects/text/text-layout.d.ts +13 -4
- package/dist/objects/text/text-layout.js +30 -4
- package/dist/space/Space.d.ts +15 -0
- package/dist/space/Space.js +16 -10
- package/dist/types/defs.d.ts +53 -2
- package/dist/utils/logging.d.ts +6 -3
- package/dist/utils/logging.js +15 -7
- package/dist/utils/math.d.ts +11 -0
- package/dist/utils/math.js +14 -0
- 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
|
|
14
|
-
*
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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,
|
|
46
|
-
// comparison
|
|
47
|
-
// `update(layer)
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
const [
|
|
53
|
-
if (!sameSet(
|
|
54
|
-
|
|
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
|
-
*
|
|
60
|
-
* `
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
92
|
-
//
|
|
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.
|
|
148
|
+
resizeTexture(texture, atlas.image, scaledRef, factor);
|
|
95
149
|
invalidate();
|
|
96
150
|
}, [texture, atlas, factor, invalidate]);
|
|
97
|
-
//
|
|
98
|
-
//
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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
|
|
147
|
-
|
|
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
|
|
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
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
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
|
|
160
|
-
* @param
|
|
161
|
-
*
|
|
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,
|
|
304
|
+
function resizeTexture(texture, full, scaledRef, factor) {
|
|
305
|
+
const scaled = scaledRef.current;
|
|
164
306
|
if (factor >= 1) {
|
|
165
|
-
// Point back at the full
|
|
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
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
const context =
|
|
179
|
-
//
|
|
180
|
-
// still draw
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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(
|
|
200
|
-
const texture = new CanvasTexture(
|
|
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
|
|
205
|
-
// down the atlas
|
|
206
|
-
// axis in the chain then points the same way
|
|
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 };
|