@expofp/renderer 3.44.8 → 3.45.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/dist/Renderer.js +7 -1
- package/dist/controls/hooks.d.ts +2 -1
- package/dist/core/sprites/BatchedSprites.js +2 -34
- package/dist/core/text/MemberTexture.js +49 -8
- package/dist/events/LayoutDriver.d.ts +5 -0
- package/dist/events/LayoutDriver.js +136 -29
- package/dist/events/layout-targets.d.ts +45 -3
- package/dist/events/layout-targets.js +129 -9
- package/dist/events/placement/box.d.ts +59 -0
- package/dist/events/placement/box.js +96 -0
- package/dist/events/placement/grid.d.ts +161 -0
- package/dist/events/placement/grid.js +235 -0
- package/dist/events/placement/index.d.ts +2 -0
- package/dist/events/placement/index.js +1 -0
- package/dist/events/placement/pass.d.ts +70 -0
- package/dist/events/placement/pass.js +617 -0
- package/dist/objects/images/ImageInstance.js +1 -0
- package/dist/objects/images/Images.js +2 -2
- package/dist/objects/images/image-container.d.ts +12 -0
- package/dist/objects/images/image-container.js +20 -4
- package/dist/objects/materials/TextureMaterial.d.ts +11 -8
- package/dist/objects/materials/TextureMaterial.js +24 -17
- package/dist/objects/text/text-container.d.ts +16 -1
- package/dist/objects/text/text-container.js +23 -1
- package/dist/space/layout-measure.d.ts +12 -10
- package/dist/space/layout-measure.js +18 -16
- package/dist/types/defs.d.ts +157 -147
- package/dist/types/events.d.ts +5 -1
- package/dist/types/guards.d.ts +7 -0
- package/dist/types/guards.js +10 -0
- package/dist/utils/math.d.ts +18 -1
- package/dist/utils/math.js +35 -0
- package/package.json +2 -2
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
import { isImageDef, isPlacementTarget } from '../../types/guards';
|
|
2
|
+
import { createLogger } from '../../utils/logging';
|
|
3
|
+
import { paddedBox, screenBoxOf, spriteBoxAt } from './box';
|
|
4
|
+
import { overlaps, PlacementGrid } from './grid';
|
|
5
|
+
/**
|
|
6
|
+
* Builds a renderer's placement pass.
|
|
7
|
+
* @returns The pass.
|
|
8
|
+
*/
|
|
9
|
+
export function createPlacement() {
|
|
10
|
+
const grid = new PlacementGrid(0, 0);
|
|
11
|
+
/**
|
|
12
|
+
* What each participant was last told, as `2 * pass + (placed ? 1 : 0)`, where `pass` is the pass
|
|
13
|
+
* that told it.
|
|
14
|
+
*
|
|
15
|
+
* The stamp is what lets one walk do two jobs. A pass records every participant it visits, and
|
|
16
|
+
* afterwards drops the entries it did not visit: a def that lost its `placement`, stopped being
|
|
17
|
+
* screen-space, or left the registry without {@link Placement.forget}. Defs that never
|
|
18
|
+
* participated have no entry and cost nothing here, which matters because a registry holds every
|
|
19
|
+
* booth label and dot. A `Map` rather than a `WeakMap` because it has to be iterated to sweep, and
|
|
20
|
+
* the sweep is also what frees an unmounted def. `forget` still exists because a def can
|
|
21
|
+
* unregister and re-register as the same object between passes, where a stale verdict would
|
|
22
|
+
* suppress its first report.
|
|
23
|
+
*/
|
|
24
|
+
const verdicts = new Map();
|
|
25
|
+
let pass = 0;
|
|
26
|
+
/** Defs already warned about, so a flat participant says so once rather than every pass. */
|
|
27
|
+
const warned = new WeakSet();
|
|
28
|
+
/**
|
|
29
|
+
* What each clustering scope last reported, and to which layers. Keyed like the scopes, and
|
|
30
|
+
* dropped for a scope that stops clustering or stops being registered.
|
|
31
|
+
*/
|
|
32
|
+
const reported = new Map();
|
|
33
|
+
return {
|
|
34
|
+
run(entries, context, onFlip, onCluster = ignoreClusters) {
|
|
35
|
+
const start = performance.now();
|
|
36
|
+
const mine = ++pass;
|
|
37
|
+
let tested = 0;
|
|
38
|
+
let flips = 0;
|
|
39
|
+
/**
|
|
40
|
+
* Stamps a participant's verdict and reports it when it flipped.
|
|
41
|
+
*
|
|
42
|
+
* One closure per pass, which at ten passes a second is nothing. Recording before reporting
|
|
43
|
+
* is what keeps a handler that mutates its def from making this pass report that def twice.
|
|
44
|
+
* @param def - The participant.
|
|
45
|
+
* @param placed - Its verdict.
|
|
46
|
+
*/
|
|
47
|
+
const record = (def, placed) => {
|
|
48
|
+
const before = verdicts.get(def);
|
|
49
|
+
verdicts.set(def, 2 * mine + (placed ? 1 : 0));
|
|
50
|
+
if (before !== undefined && before % 2 === (placed ? 1 : 0))
|
|
51
|
+
return;
|
|
52
|
+
flips++;
|
|
53
|
+
onFlip(def, placed);
|
|
54
|
+
};
|
|
55
|
+
const scopes = scopesOf(entries);
|
|
56
|
+
for (const [scopeKey, scope] of scopes) {
|
|
57
|
+
// Once per scope, never once per pass: a scope that inherited the previous one's boxes
|
|
58
|
+
// would let collision cross the boundary the scope exists to draw. Deliberately not
|
|
59
|
+
// protected against re-entry, unlike `decided` and `index` below: a nested `run` would
|
|
60
|
+
// reset this grid out from under the outer pass. Unreachable today, because the contract
|
|
61
|
+
// forbids `update()` from `onPlace`, which is the only way to start a nested pass.
|
|
62
|
+
grid.reset(context.width, context.height);
|
|
63
|
+
const clustering = scope.layers.some((layer) => layer.clustering !== undefined);
|
|
64
|
+
/**
|
|
65
|
+
* Every verdict this scope's walk reached, in walk order. Nothing is reported until the walk
|
|
66
|
+
* ends, because a seed placed early is hidden by a def the walk reaches later. A `Map` keeps
|
|
67
|
+
* a key's first position when it is set again, so a revised seed still reports where the
|
|
68
|
+
* walk first decided it.
|
|
69
|
+
*/
|
|
70
|
+
const pending = new Map();
|
|
71
|
+
/**
|
|
72
|
+
* The defs each grid entry stands for, indexed by the entry's id: one def, or every member of
|
|
73
|
+
* the group whose union the entry is. Filled only in a clustering scope, which is the only
|
|
74
|
+
* one that needs to know whom a blocker stands for, and whom a marker absorbs; an entry
|
|
75
|
+
* whose unit has joined a cluster is emptied after the walk.
|
|
76
|
+
*/
|
|
77
|
+
const placedDefs = [];
|
|
78
|
+
/** Each cluster's members, seed first, keyed by the seed's grid entry. */
|
|
79
|
+
const seeded = new Map();
|
|
80
|
+
/**
|
|
81
|
+
* Settles one unit, a lone def or a whole group, against the grid.
|
|
82
|
+
* @param unit - The unit's defs.
|
|
83
|
+
* @param test - The box it asks for, or `undefined` when nothing of it could be measured
|
|
84
|
+
* on the grid, or it does not compete: then it is placed, and it is in no cluster.
|
|
85
|
+
* @param store - The box it occupies once placed, inserted when `test` is clear.
|
|
86
|
+
* @param overlap - How it competes.
|
|
87
|
+
*/
|
|
88
|
+
const settle = (unit, test, store, overlap) => {
|
|
89
|
+
const blocker = test === undefined ? undefined : grid.blockerOf(test, overlap);
|
|
90
|
+
if (blocker === undefined) {
|
|
91
|
+
// An always def is drawn without competing and is not inserted, so it blocks nobody.
|
|
92
|
+
if (test !== undefined && overlap !== 'always') {
|
|
93
|
+
const id = grid.insert(store, overlap);
|
|
94
|
+
if (clustering)
|
|
95
|
+
placedDefs[id] = unit;
|
|
96
|
+
}
|
|
97
|
+
for (const def of unit)
|
|
98
|
+
pending.set(def, true);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
for (const def of unit)
|
|
102
|
+
pending.set(def, false);
|
|
103
|
+
// Without clustering the blocker keeps the room and the verdict it had.
|
|
104
|
+
if (!clustering)
|
|
105
|
+
return;
|
|
106
|
+
let members = seeded.get(blocker);
|
|
107
|
+
if (members === undefined) {
|
|
108
|
+
// The blocker becomes a seed: it stays in the grid, so a later def colliding with it
|
|
109
|
+
// joins this cluster rather than starting one, and it hides with its first joiner.
|
|
110
|
+
members = [...placedDefs[blocker]];
|
|
111
|
+
for (const def of members)
|
|
112
|
+
pending.set(def, false);
|
|
113
|
+
seeded.set(blocker, members);
|
|
114
|
+
}
|
|
115
|
+
for (const def of unit)
|
|
116
|
+
members.push(def);
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* The group keys this scope has already decided.
|
|
120
|
+
*
|
|
121
|
+
* Run-local, like `index` below, rather than closure state cleared at the top of each pass: a
|
|
122
|
+
* `run` re-entered from an `onPlace` handler would clear a shared set mid-walk, and the outer
|
|
123
|
+
* walk would then decide the later members of a group it had already settled. A `const` here
|
|
124
|
+
* cannot be reached by an inner pass at all.
|
|
125
|
+
*
|
|
126
|
+
* Per scope rather than per pass, because a key is resolved per scope: members of one key in
|
|
127
|
+
* two scopes are two groups, and each has to be decided in its own scope or it would never be
|
|
128
|
+
* decided at all.
|
|
129
|
+
*
|
|
130
|
+
* A group is decided when the walk reaches its **first** member, which makes the first
|
|
131
|
+
* member's position the whole group's priority. Every later member is skipped, because it
|
|
132
|
+
* already has its verdict. Deciding at the last member instead would let a def walked between
|
|
133
|
+
* two members take room from the first, which is not what first come, first served means.
|
|
134
|
+
*/
|
|
135
|
+
const decided = new Set();
|
|
136
|
+
/**
|
|
137
|
+
* Every grouped participant of this scope by its key, in walk order. Built on the first
|
|
138
|
+
* grouped def the walk reaches and not before, so a scope with no groups is walked exactly
|
|
139
|
+
* once.
|
|
140
|
+
*/
|
|
141
|
+
let index;
|
|
142
|
+
for (const defs of scope.defs) {
|
|
143
|
+
for (const def of defs) {
|
|
144
|
+
const options = def.placement;
|
|
145
|
+
if (options === undefined)
|
|
146
|
+
continue;
|
|
147
|
+
if (!isPlacementTarget(def)) {
|
|
148
|
+
if (!warned.has(def)) {
|
|
149
|
+
warned.add(def);
|
|
150
|
+
logger.warn('placement is not supported for a flat def yet; set screenSpace to place it %O', def);
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const key = options.group;
|
|
155
|
+
if (key !== undefined) {
|
|
156
|
+
if (decided.has(key))
|
|
157
|
+
continue;
|
|
158
|
+
decided.add(key);
|
|
159
|
+
index ??= indexGroups(scope.defs);
|
|
160
|
+
const members = index.get(key);
|
|
161
|
+
// Only absent when a handler moved a def into this group mid-pass, after the index was
|
|
162
|
+
// built, which the `onPlace` contract rules out.
|
|
163
|
+
if (members === undefined)
|
|
164
|
+
continue;
|
|
165
|
+
tested += members.length;
|
|
166
|
+
// A folded member's padded box is on the grid, so the padded union that contains it
|
|
167
|
+
// is too, and needs no `intersects` of its own.
|
|
168
|
+
const folded = unionOf(members, grid, context);
|
|
169
|
+
settle(members, folded ? _grownUnion : undefined, _union, 'never');
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
tested++;
|
|
173
|
+
const overlap = options.overlap ?? 'never';
|
|
174
|
+
const box = screenBoxOf(def, context.space, context.camera, context.pixelSize, _box);
|
|
175
|
+
// The grown box is tested and the plain extent is stored, so a def's padding is the room
|
|
176
|
+
// it asks of the others, and is not asked of it again by each of them. A box that did not
|
|
177
|
+
// project, or that the grid cannot hold, is placed and competes with nobody: a failed
|
|
178
|
+
// measurement is not a collision, and in a clustering scope a def must never be hidden
|
|
179
|
+
// without being counted.
|
|
180
|
+
const grown = box === undefined ? undefined : paddedBox(def, box, _grown);
|
|
181
|
+
const test = grown !== undefined && grid.intersects(grown) ? grown : undefined;
|
|
182
|
+
settle([def], test, _box, overlap);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const next = clustering
|
|
186
|
+
? converge(seeded, placedDefs, grid, markerOf(scope), context, pending)
|
|
187
|
+
: [];
|
|
188
|
+
const last = reported.get(scopeKey);
|
|
189
|
+
const changed = clustering && !sameMembers(next, last?.members ?? NO_CLUSTERS);
|
|
190
|
+
for (const [def, placed] of pending)
|
|
191
|
+
record(def, placed);
|
|
192
|
+
// A layer told of clusters that is no longer a clustering layer here hears the empty set,
|
|
193
|
+
// or its last markers would stay on screen. Before the rest, so a caller that moved the
|
|
194
|
+
// layer's role elsewhere clears before it redraws.
|
|
195
|
+
if (last !== undefined && last.members.length > 0) {
|
|
196
|
+
for (const [layer, options] of last.told) {
|
|
197
|
+
if (layer.clustering === undefined || !scope.layers.includes(layer)) {
|
|
198
|
+
onCluster(layer, [], options);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (!clustering) {
|
|
203
|
+
reported.delete(scopeKey);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
// An unchanged set still goes to a clustering layer that joined the scope since the last
|
|
207
|
+
// report, which has never been told it. An empty one goes to nobody new: never having been
|
|
208
|
+
// told anything is already the truth.
|
|
209
|
+
if (!changed && (last === undefined || last.members.length === 0))
|
|
210
|
+
continue;
|
|
211
|
+
let clusters;
|
|
212
|
+
const told = [];
|
|
213
|
+
for (const layer of scope.layers) {
|
|
214
|
+
const options = layer.clustering;
|
|
215
|
+
if (options === undefined)
|
|
216
|
+
continue;
|
|
217
|
+
told.push([layer, options]);
|
|
218
|
+
if (!changed && last.told.some(([was]) => was === layer))
|
|
219
|
+
continue;
|
|
220
|
+
clusters ??= next.map(clusterOf);
|
|
221
|
+
onCluster(layer, [...clusters], options);
|
|
222
|
+
}
|
|
223
|
+
reported.set(scopeKey, { members: next, told });
|
|
224
|
+
}
|
|
225
|
+
// A scope no longer registered, because its layers' participants all left, takes its clusters
|
|
226
|
+
// with it: each layer that was told of some hears the empty set, or it would go on drawing
|
|
227
|
+
// them. Then the record goes, so that the scope reports afresh if it comes back.
|
|
228
|
+
if (reported.size > 0) {
|
|
229
|
+
for (const [key, { members, told }] of reported) {
|
|
230
|
+
if (scopes.has(key))
|
|
231
|
+
continue;
|
|
232
|
+
reported.delete(key);
|
|
233
|
+
if (members.length === 0)
|
|
234
|
+
continue;
|
|
235
|
+
for (const [layer, options] of told)
|
|
236
|
+
onCluster(layer, [], options);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Drop what this pass did not visit. Only a stamp older than this pass goes: a pass a handler
|
|
240
|
+
// started from inside this one has stamped its own defs newer, and those stay.
|
|
241
|
+
if (verdicts.size > tested) {
|
|
242
|
+
for (const [def, stamp] of verdicts) {
|
|
243
|
+
if (stamp < 2 * mine)
|
|
244
|
+
verdicts.delete(def);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (flips > 0 && logger.debug.enabled) {
|
|
248
|
+
const took = (performance.now() - start).toFixed(2);
|
|
249
|
+
logger.debug(`placed ${tested} defs, ${flips} flipped, in ${took}ms`);
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
forget(def) {
|
|
253
|
+
verdicts.delete(def);
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Buckets the registered def arrays into collision scopes, keyed on the layer's
|
|
259
|
+
* {@link LayerDef.collisionGroup} or, when it names none, on the layer itself.
|
|
260
|
+
*
|
|
261
|
+
* Keyed on the layer object, never on its `name`: two layers that happen to share a name are still
|
|
262
|
+
* two scopes. A `Map` keeps insertion order, so the scopes come out in the order their first array
|
|
263
|
+
* was registered, and each scope's arrays in registration order.
|
|
264
|
+
* @param entries - Each registered def array with the layer that registered it, in registration
|
|
265
|
+
* order.
|
|
266
|
+
* @returns The scopes, in walk order.
|
|
267
|
+
*/
|
|
268
|
+
function scopesOf(entries) {
|
|
269
|
+
const scopes = new Map();
|
|
270
|
+
for (const [defs, layer] of entries) {
|
|
271
|
+
const key = layer.collisionGroup ?? layer;
|
|
272
|
+
const scope = scopes.get(key);
|
|
273
|
+
if (scope === undefined) {
|
|
274
|
+
scopes.set(key, { layers: [layer], defs: [defs] });
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (!scope.layers.includes(layer))
|
|
278
|
+
scope.layers.push(layer);
|
|
279
|
+
scope.defs.push(defs);
|
|
280
|
+
}
|
|
281
|
+
return scopes;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Collects every grouped participant of one scope by its key, in the walk's own order.
|
|
285
|
+
*
|
|
286
|
+
* A flat def carrying a group is left out: it is not a participant, and the main walk is where it
|
|
287
|
+
* is warned about. Rebuilt for every scope of every pass, which is what keeps it current as defs
|
|
288
|
+
* join and leave; a scope holds a handful of arrays, so the second walk is not worth a cache.
|
|
289
|
+
* @param groups - The scope's def arrays, in registration order.
|
|
290
|
+
* @returns The participants of each group, keyed by `PlacementOptions.group`.
|
|
291
|
+
*/
|
|
292
|
+
function indexGroups(groups) {
|
|
293
|
+
const index = new Map();
|
|
294
|
+
for (const defs of groups) {
|
|
295
|
+
for (const def of defs) {
|
|
296
|
+
const key = def.placement?.group;
|
|
297
|
+
if (key === undefined || !isPlacementTarget(def))
|
|
298
|
+
continue;
|
|
299
|
+
const members = index.get(key);
|
|
300
|
+
if (members === undefined)
|
|
301
|
+
index.set(key, [def]);
|
|
302
|
+
else
|
|
303
|
+
members.push(def);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return index;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Folds one group's boxes into one, so the group is tested and inserted as one unit.
|
|
310
|
+
*
|
|
311
|
+
* The unit's box is the **union** of its members' boxes, each grown by its own padding before the
|
|
312
|
+
* fold, so one box is tested and one box is inserted, and a group's members can never block each
|
|
313
|
+
* other or disagree. The plain extents are folded alongside into {@link _union}, and the padded
|
|
314
|
+
* ones into {@link _grownUnion}, because the grid stores what a def occupies and tests what it asks
|
|
315
|
+
* for.
|
|
316
|
+
*
|
|
317
|
+
* A member is folded only when it produced a box the grid's area covers. One that did not, and one
|
|
318
|
+
* that is `always`, is left out of the union, because a failed measurement is not a collision and
|
|
319
|
+
* an `always` member competes with nobody; both still take the unit's verdict.
|
|
320
|
+
*
|
|
321
|
+
* When nothing was folded there is nothing to test, and the unit is placed and is in no cluster,
|
|
322
|
+
* which is what a lone def that could not be measured gets. Either way a group of one behaves
|
|
323
|
+
* exactly like that def ungrouped.
|
|
324
|
+
* @param members - The group's participants, in walk order.
|
|
325
|
+
* @param grid - The pass's grid.
|
|
326
|
+
* @param context - What the camera shows this pass.
|
|
327
|
+
* @returns Whether any member was folded, so that the two scratch unions hold the unit's boxes.
|
|
328
|
+
*/
|
|
329
|
+
function unionOf(members, grid, context) {
|
|
330
|
+
let folded = 0;
|
|
331
|
+
for (const def of members) {
|
|
332
|
+
if ((def.placement?.overlap ?? 'never') === 'always')
|
|
333
|
+
continue;
|
|
334
|
+
const extent = screenBoxOf(def, context.space, context.camera, context.pixelSize, _box);
|
|
335
|
+
if (extent === undefined)
|
|
336
|
+
continue;
|
|
337
|
+
const grown = paddedBox(def, extent, _grown);
|
|
338
|
+
if (!grid.intersects(grown))
|
|
339
|
+
continue;
|
|
340
|
+
if (folded++ === 0) {
|
|
341
|
+
copyBox(extent, _union);
|
|
342
|
+
copyBox(grown, _grownUnion);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
foldBox(extent, _union);
|
|
346
|
+
foldBox(grown, _grownUnion);
|
|
347
|
+
}
|
|
348
|
+
return folded > 0;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Lets each cluster's marker absorb what it overlaps, until nothing changes, and lists the clusters
|
|
352
|
+
* that remain.
|
|
353
|
+
*
|
|
354
|
+
* Runs after a clustering scope's walk and before anything is reported. Each cluster's marker box
|
|
355
|
+
* stands at the cluster's anchor, which is what the caller is handed. A placed unit whose stored
|
|
356
|
+
* box it overlaps joins the cluster, every member hidden; a cluster whose marker box it overlaps is
|
|
357
|
+
* merged into whichever of the two has the earlier seed. Either way the cluster's anchor moves, so
|
|
358
|
+
* its box is measured again and tested again; a cluster that did not change is not, since it can
|
|
359
|
+
* find nothing new. Every change removes a unit or a cluster from what is left to test, so the loop
|
|
360
|
+
* ends; members never leave.
|
|
361
|
+
*
|
|
362
|
+
* Brute force, both ways: a scope holds a few hundred participants at most, and the clusters only
|
|
363
|
+
* a fraction of that. Clusters are visited in seed order and units in grid entry order, which is
|
|
364
|
+
* walk order, so the answer depends on nothing but the registration and the camera.
|
|
365
|
+
* @param seeded - Each cluster's members, seed first, by the seed's grid entry.
|
|
366
|
+
* @param placedDefs - The defs each grid entry stands for. A seed's entry and an absorbed unit's
|
|
367
|
+
* are emptied here.
|
|
368
|
+
* @param grid - The scope's grid, holding each placed unit's box.
|
|
369
|
+
* @param marker - The scope's declared marker, or nothing to take each cluster's first member's.
|
|
370
|
+
* @param context - What the camera shows this pass.
|
|
371
|
+
* @param pending - The scope's verdicts so far, where an absorbed unit's members are hidden.
|
|
372
|
+
* @returns The clusters' members, in seed order, each cluster's seed first and each unit whole.
|
|
373
|
+
*/
|
|
374
|
+
function converge(seeded, placedDefs, grid, marker, context, pending) {
|
|
375
|
+
const ids = [...seeded.keys()].sort(byId);
|
|
376
|
+
const clusters = ids.map((id) => seeded.get(id));
|
|
377
|
+
if (clusters.length === 0)
|
|
378
|
+
return clusters;
|
|
379
|
+
// A seed belongs to its cluster now, so what is left in `placedDefs` is the lone placed units.
|
|
380
|
+
for (const id of ids)
|
|
381
|
+
placedDefs[id] = undefined;
|
|
382
|
+
const boxes = [];
|
|
383
|
+
const measured = [];
|
|
384
|
+
const alive = [];
|
|
385
|
+
/**
|
|
386
|
+
* Whether a cluster can find anything new when it is next visited: it changed, or a cluster that
|
|
387
|
+
* changed now overlaps it. The lone units' boxes never move, so nothing else can give a visit
|
|
388
|
+
* work, and skipping every other cluster gives exactly the answer of re-testing all of them.
|
|
389
|
+
*/
|
|
390
|
+
const dirty = [];
|
|
391
|
+
for (const members of clusters) {
|
|
392
|
+
const box = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
|
393
|
+
boxes.push(box);
|
|
394
|
+
measured.push(markerBoxOf(members, marker, context, box) !== undefined);
|
|
395
|
+
alive.push(true);
|
|
396
|
+
dirty.push(true);
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Marks a cluster whose box was just measured again, and every live cluster its box now overlaps.
|
|
400
|
+
* @param k - The cluster that changed.
|
|
401
|
+
*/
|
|
402
|
+
const touch = (k) => {
|
|
403
|
+
dirty[k] = true;
|
|
404
|
+
if (!measured[k])
|
|
405
|
+
return;
|
|
406
|
+
for (let e = 0; e < clusters.length; e++) {
|
|
407
|
+
if (e !== k && alive[e] && measured[e] && overlaps(boxes[k], boxes[e]))
|
|
408
|
+
dirty[e] = true;
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
for (let changed = true; changed;) {
|
|
412
|
+
changed = false;
|
|
413
|
+
for (let c = 0; c < clusters.length; c++) {
|
|
414
|
+
if (!alive[c] || !dirty[c])
|
|
415
|
+
continue;
|
|
416
|
+
dirty[c] = false;
|
|
417
|
+
// A marker that did not project covers nothing, and nothing can be said to overlap it.
|
|
418
|
+
for (let id = 0; id < placedDefs.length && measured[c]; id++) {
|
|
419
|
+
const unit = placedDefs[id];
|
|
420
|
+
if (unit === undefined || !overlaps(boxes[c], grid.boxOf(id, _entry)))
|
|
421
|
+
continue;
|
|
422
|
+
placedDefs[id] = undefined;
|
|
423
|
+
for (const def of unit) {
|
|
424
|
+
pending.set(def, false);
|
|
425
|
+
clusters[c].push(def);
|
|
426
|
+
}
|
|
427
|
+
measured[c] = markerBoxOf(clusters[c], marker, context, boxes[c]) !== undefined;
|
|
428
|
+
touch(c);
|
|
429
|
+
changed = true;
|
|
430
|
+
}
|
|
431
|
+
for (let d = 0; d < clusters.length && measured[c]; d++) {
|
|
432
|
+
if (d === c || !alive[d] || !measured[d] || !overlaps(boxes[c], boxes[d]))
|
|
433
|
+
continue;
|
|
434
|
+
// The earlier seed stays the seed, so the merged cluster keeps its place in seed order.
|
|
435
|
+
const keep = Math.min(c, d);
|
|
436
|
+
const gone = Math.max(c, d);
|
|
437
|
+
for (const def of clusters[gone])
|
|
438
|
+
clusters[keep].push(def);
|
|
439
|
+
alive[gone] = false;
|
|
440
|
+
measured[keep] = markerBoxOf(clusters[keep], marker, context, boxes[keep]) !== undefined;
|
|
441
|
+
touch(keep);
|
|
442
|
+
changed = true;
|
|
443
|
+
// Merged into an earlier cluster, this one is gone; the survivor is tested next sweep.
|
|
444
|
+
if (gone === c)
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return clusters.filter((_, c) => alive[c]);
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* The marker a scope's clusters are drawn with: the one declared by the scope's first clustering
|
|
453
|
+
* layer, in registration order, so that one scope has one answer however many layers declare one.
|
|
454
|
+
* @param scope - The scope.
|
|
455
|
+
* @returns The declared marker, or nothing to take each cluster's first member's box.
|
|
456
|
+
*/
|
|
457
|
+
function markerOf(scope) {
|
|
458
|
+
return scope.layers.find((layer) => layer.clustering !== undefined)?.clustering?.marker;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* The screen box of a cluster's marker, standing at the cluster's anchor.
|
|
462
|
+
*
|
|
463
|
+
* A declared marker gives the size and origin. Without one the box takes the size, origin and
|
|
464
|
+
* rotation of the cluster's first member, skipping an `always` member, which competes with nobody,
|
|
465
|
+
* and one with no extent, measured as the walk measures it.
|
|
466
|
+
* @param members - The cluster's members, seed first.
|
|
467
|
+
* @param marker - The scope's declared marker, if any.
|
|
468
|
+
* @param context - What the camera shows this pass.
|
|
469
|
+
* @param out - The box to write into.
|
|
470
|
+
* @returns The box, or nothing when the anchor does not project or nothing has an extent.
|
|
471
|
+
*/
|
|
472
|
+
function markerBoxOf(members, marker, context, out) {
|
|
473
|
+
const { x, y, elevation } = centroidOf(members, _centroid);
|
|
474
|
+
const { space, camera, pixelSize } = context;
|
|
475
|
+
if (marker !== undefined) {
|
|
476
|
+
const { width, height, origin } = marker;
|
|
477
|
+
return spriteBoxAt(x, y, elevation, width, height, origin, 0, space, camera, out);
|
|
478
|
+
}
|
|
479
|
+
if (!Number.isFinite(pixelSize) || pixelSize <= 0)
|
|
480
|
+
return undefined;
|
|
481
|
+
for (const def of members) {
|
|
482
|
+
if ((def.placement?.overlap ?? 'never') === 'always')
|
|
483
|
+
continue;
|
|
484
|
+
const { size, rotation } = def.bounds;
|
|
485
|
+
const width = size.x / pixelSize;
|
|
486
|
+
const height = size.y / pixelSize;
|
|
487
|
+
if (!(width > 0) || !(height > 0))
|
|
488
|
+
continue;
|
|
489
|
+
const origin = isImageDef(def) ? def.origin : undefined;
|
|
490
|
+
return spriteBoxAt(x, y, elevation, width, height, origin, rotation, space, camera, out);
|
|
491
|
+
}
|
|
492
|
+
return undefined;
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Builds the cluster a pass reports from one cluster's members.
|
|
496
|
+
*
|
|
497
|
+
* The members are folded into **units** first: one per `placement.group` key, one per member
|
|
498
|
+
* carrying none. A group's members are contiguous here, because a unit joins a cluster whole, so
|
|
499
|
+
* a run of one key is one unit. A unit's anchor is its own `bounds.center`, or for a group the mean
|
|
500
|
+
* of its members'; the cluster's anchor is the mean of the units', so a group holding more defs
|
|
501
|
+
* does not pull it further. The elevation is the greatest of the members', so what stands for the
|
|
502
|
+
* cluster clears the tallest thing it covers.
|
|
503
|
+
* @param members - The cluster's members, seed first, each unit whole.
|
|
504
|
+
* @returns The cluster.
|
|
505
|
+
*/
|
|
506
|
+
function clusterOf(members) {
|
|
507
|
+
return centroidOf(members, { unitCount: 0, x: 0, y: 0, elevation: 0 });
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Folds one cluster's members into its unit count, anchor and elevation, as {@link clusterOf}
|
|
511
|
+
* describes, into an object the caller owns.
|
|
512
|
+
* @param members - The cluster's members, seed first, each unit whole.
|
|
513
|
+
* @param out - Where to write the totals.
|
|
514
|
+
* @returns `out`.
|
|
515
|
+
*/
|
|
516
|
+
function centroidOf(members, out) {
|
|
517
|
+
let unitCount = 0;
|
|
518
|
+
let x = 0;
|
|
519
|
+
let y = 0;
|
|
520
|
+
let elevation = -Infinity;
|
|
521
|
+
for (let i = 0; i < members.length;) {
|
|
522
|
+
const key = members[i].placement?.group;
|
|
523
|
+
let sumX = 0;
|
|
524
|
+
let sumY = 0;
|
|
525
|
+
let count = 0;
|
|
526
|
+
do {
|
|
527
|
+
const { bounds } = members[i];
|
|
528
|
+
sumX += bounds.center.x;
|
|
529
|
+
sumY += bounds.center.y;
|
|
530
|
+
elevation = Math.max(elevation, bounds.elevation);
|
|
531
|
+
count++;
|
|
532
|
+
i++;
|
|
533
|
+
} while (key !== undefined && i < members.length && members[i].placement?.group === key);
|
|
534
|
+
unitCount++;
|
|
535
|
+
x += sumX / count;
|
|
536
|
+
y += sumY / count;
|
|
537
|
+
}
|
|
538
|
+
out.unitCount = unitCount;
|
|
539
|
+
out.x = x / unitCount;
|
|
540
|
+
out.y = y / unitCount;
|
|
541
|
+
out.elevation = elevation;
|
|
542
|
+
return out;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Whether two cluster sets hold the same members in the same order: the cluster count, then each
|
|
546
|
+
* cluster's member count, then each member's identity. Order counts, because the seed is part of
|
|
547
|
+
* the answer even where the numbers a caller is handed come out the same.
|
|
548
|
+
* @param next - This pass's clusters.
|
|
549
|
+
* @param last - The clusters last reported.
|
|
550
|
+
* @returns Whether nothing changed.
|
|
551
|
+
*/
|
|
552
|
+
function sameMembers(next, last) {
|
|
553
|
+
if (next.length !== last.length)
|
|
554
|
+
return false;
|
|
555
|
+
for (let c = 0; c < next.length; c++) {
|
|
556
|
+
const a = next[c];
|
|
557
|
+
const b = last[c];
|
|
558
|
+
if (a.length !== b.length)
|
|
559
|
+
return false;
|
|
560
|
+
for (let m = 0; m < a.length; m++) {
|
|
561
|
+
if (a[m] !== b[m])
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return true;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Orders grid entry ids ascending, which is insert order, which is walk order.
|
|
569
|
+
* @param a - One id.
|
|
570
|
+
* @param b - The other.
|
|
571
|
+
* @returns Their difference.
|
|
572
|
+
*/
|
|
573
|
+
function byId(a, b) {
|
|
574
|
+
return a - b;
|
|
575
|
+
}
|
|
576
|
+
/** The `onCluster` a caller that clusters nothing passes by omitting it. */
|
|
577
|
+
function ignoreClusters() {
|
|
578
|
+
// Nothing to report to.
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Copies one box's edges into another.
|
|
582
|
+
* @param from - The box to copy.
|
|
583
|
+
* @param out - The box to write into.
|
|
584
|
+
*/
|
|
585
|
+
function copyBox(from, out) {
|
|
586
|
+
out.minX = from.minX;
|
|
587
|
+
out.minY = from.minY;
|
|
588
|
+
out.maxX = from.maxX;
|
|
589
|
+
out.maxY = from.maxY;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Grows a box to cover another.
|
|
593
|
+
* @param box - The box to cover.
|
|
594
|
+
* @param out - The running union, grown in place.
|
|
595
|
+
*/
|
|
596
|
+
function foldBox(box, out) {
|
|
597
|
+
out.minX = Math.min(out.minX, box.minX);
|
|
598
|
+
out.minY = Math.min(out.minY, box.minY);
|
|
599
|
+
out.maxX = Math.max(out.maxX, box.maxX);
|
|
600
|
+
out.maxY = Math.max(out.maxY, box.maxY);
|
|
601
|
+
}
|
|
602
|
+
const logger = createLogger('placement');
|
|
603
|
+
/** The set a scope has before it ever reported one: no clusters. */
|
|
604
|
+
const NO_CLUSTERS = [];
|
|
605
|
+
// Scratch for the one box a pass measures at a time, and its padded twin. Each is filled and
|
|
606
|
+
// consumed within one non-re-entrant pass, across all `Placement` instances, since both are module
|
|
607
|
+
// scope.
|
|
608
|
+
const _box = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
|
609
|
+
const _grown = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
|
610
|
+
// Scratch for a group's union: its members' plain extents and their padded boxes, folded in
|
|
611
|
+
// step. Filled and consumed within one non-re-entrant pass, like the two boxes above.
|
|
612
|
+
const _union = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
|
613
|
+
const _grownUnion = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
|
614
|
+
// Scratch for the merge loop after a clustering scope's walk: a placed unit's box read back from
|
|
615
|
+
// the grid, and the anchor a marker box is measured around. Filled and consumed within one call.
|
|
616
|
+
const _entry = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
|
617
|
+
const _centroid = { unitCount: 0, x: 0, y: 0, elevation: 0 };
|
|
@@ -24,6 +24,7 @@ export const ImageInstance = memo(function ImageInstance(props) {
|
|
|
24
24
|
const tile = tileRect(packed);
|
|
25
25
|
writeImageMatrix(container, slot, def, tile);
|
|
26
26
|
writeImageAnchor(container, slot, def, tile);
|
|
27
|
+
container.setOpacityAt(slot, def.opacity);
|
|
27
28
|
// Checked on every sync rather than behind the write, because screenSpace is an ordinary def
|
|
28
29
|
// field. Flipping it reaches this instance and stops there, so this is where a mesh learns that
|
|
29
30
|
// it holds something it should not. The atlas re-filters, which never touches the packing.
|
|
@@ -8,7 +8,7 @@ import { createLogger } from '../../utils/logging';
|
|
|
8
8
|
import { defKey, useContainer } from '../containers';
|
|
9
9
|
import { TextureMaterial } from '../materials/TextureMaterial';
|
|
10
10
|
import { packAtlases, } from './atlas-packer';
|
|
11
|
-
import { ImageContainer, UV_OFFSET_UNIFORM } from './image-container';
|
|
11
|
+
import { ImageContainer, OPACITY_UNIFORM, UV_OFFSET_UNIFORM } from './image-container';
|
|
12
12
|
import { ImageInstance } from './ImageInstance';
|
|
13
13
|
import { contextOf, createAtlasCanvas } from './pack-sources';
|
|
14
14
|
import { TextureBudgetContext, useResizeFactor } from './texture-budget';
|
|
@@ -211,7 +211,7 @@ function AtlasMesh(props) {
|
|
|
211
211
|
// args is a frozen snapshot, so R3F never rebuilds the mesh. The ref arrow, rather than
|
|
212
212
|
// ref={container.bind}, keeps the React Compiler from inferring container itself as a ref and
|
|
213
213
|
// rejecting its render reads.
|
|
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 })] }));
|
|
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, opacityUniform: OPACITY_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, opacityUniform: OPACITY_UNIFORM })] }));
|
|
215
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}`)))] }));
|
|
216
216
|
}
|
|
217
217
|
/**
|
|
@@ -10,6 +10,12 @@ import { type PackedImage } from './atlas-packer';
|
|
|
10
10
|
* `uv * zw + xy` maps the quad's unit uv onto the region.
|
|
11
11
|
*/
|
|
12
12
|
export declare const UV_OFFSET_UNIFORM = "uvOffset";
|
|
13
|
+
/**
|
|
14
|
+
* The per-instance uniform each atlas mesh reads its opacity from. The container owns the name and
|
|
15
|
+
* hands it to the mesh's `TextureMaterial` as the `opacityUniform` prop, so the material patches its shader
|
|
16
|
+
* without knowing the name itself.
|
|
17
|
+
*/
|
|
18
|
+
export declare const OPACITY_UNIFORM = "instanceOpacity";
|
|
13
19
|
/**
|
|
14
20
|
* The constructor tuple R3F builds one atlas's mesh from, passed as `<instancedMesh args={…}>`.
|
|
15
21
|
* R3F diffs `args` element-wise and rebuilds the object when any element changes, so this is
|
|
@@ -56,6 +62,12 @@ export declare class ImageContainer extends SlotContainer<PackedImage, Instanced
|
|
|
56
62
|
* @returns Whether this container holds a sprite batch.
|
|
57
63
|
*/
|
|
58
64
|
get facesCamera(): boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Writes a def's opacity into its slot, clamped, treating an absent value as fully opaque.
|
|
67
|
+
* @param slot - The instance's slot.
|
|
68
|
+
* @param opacity - The def's opacity, or `undefined` for the default.
|
|
69
|
+
*/
|
|
70
|
+
setOpacityAt(slot: number, opacity: number | undefined): void;
|
|
59
71
|
protected addInstance(packed: PackedImage): number;
|
|
60
72
|
protected removeInstance(slot: number): void;
|
|
61
73
|
}
|