@expofp/renderer 3.39.0 → 3.39.1
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/events/LayoutDriver.d.ts +6 -6
- package/dist/events/LayoutDriver.js +9 -12
- package/dist/events/layout-targets.d.ts +35 -34
- package/dist/events/layout-targets.js +127 -107
- package/dist/objects/containers/hooks.d.ts +12 -2
- package/dist/objects/containers/hooks.js +83 -20
- package/dist/objects/images/ImageInstance.js +1 -1
- package/dist/objects/layer/Layer.js +46 -36
- package/dist/objects/layer/layer-context.d.ts +39 -6
- package/dist/objects/layer/layer-context.js +44 -7
- package/dist/objects/materials/effects/color-dim.d.ts +3 -2
- package/dist/objects/materials/effects/color-dim.js +16 -8
- package/dist/objects/shapes/ShapeInstance.js +1 -2
- package/dist/objects/text/TextInstance.js +1 -1
- package/package.json +2 -2
|
@@ -14,12 +14,12 @@ export interface LayoutDriverProps {
|
|
|
14
14
|
* for: under the on-demand loop the camera's own movement is what requests them, so a still plan
|
|
15
15
|
* costs nothing at all.
|
|
16
16
|
*
|
|
17
|
-
* A move
|
|
18
|
-
* at most {@link LAYOUT_BUDGET_MS},
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* stay inside the frame callback, before three's own
|
|
22
|
-
* the very frame that draws them.
|
|
17
|
+
* A move runs every stale rule at once — the cheap half — and starts a **sweep** of the writes: each
|
|
18
|
+
* frame drains the registry's write queue for at most {@link LAYOUT_BUDGET_MS}, visibility flips
|
|
19
|
+
* first and then defs inside the camera's frustum, and asks for another frame while any remain — so
|
|
20
|
+
* a plan of ten thousand labels re-writes over a handful of frames instead of stalling one, and what
|
|
21
|
+
* the user sees change lands first. The writes stay inside the frame callback, before three's own
|
|
22
|
+
* render, so they upload in the very frame that draws them.
|
|
23
23
|
*
|
|
24
24
|
* No React is involved in a re-layout. The handlers mutate def fields and the defs' own sync closures
|
|
25
25
|
* write them, so a zoom does not re-render a single component — which is what makes it affordable for
|
|
@@ -5,14 +5,12 @@ import { Frustum } from 'three';
|
|
|
5
5
|
import { useCameraControls } from '../controls';
|
|
6
6
|
import { measurePixelSize, measureRoll, pixelSizeMoved } from '../space/layout-measure';
|
|
7
7
|
import { useSpace } from '../space/Space';
|
|
8
|
-
import { createLogger } from '../utils/logging';
|
|
9
8
|
import { planFrustum } from './def-culling';
|
|
10
9
|
import { createLayoutTargets, LayoutTargetsContext } from './layout-targets';
|
|
11
|
-
const logger = createLogger('layout');
|
|
12
10
|
/**
|
|
13
|
-
* How much of a frame one drain may spend
|
|
14
|
-
* the legacy renderer's `UpdatesSystem` drained its update queue with. Small enough
|
|
15
|
-
* 60fps frame its render; the backlog it leaves is carried into the next frame
|
|
11
|
+
* How much of a frame one drain may spend writing laid-out defs into their slots, in milliseconds —
|
|
12
|
+
* the same budget the legacy renderer's `UpdatesSystem` drained its update queue with. Small enough
|
|
13
|
+
* to leave a 60fps frame its render; the backlog it leaves is carried into the next frame.
|
|
16
14
|
*/
|
|
17
15
|
const LAYOUT_BUDGET_MS = 3;
|
|
18
16
|
/**
|
|
@@ -25,12 +23,12 @@ const LAYOUT_BUDGET_MS = 3;
|
|
|
25
23
|
* for: under the on-demand loop the camera's own movement is what requests them, so a still plan
|
|
26
24
|
* costs nothing at all.
|
|
27
25
|
*
|
|
28
|
-
* A move
|
|
29
|
-
* at most {@link LAYOUT_BUDGET_MS},
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* stay inside the frame callback, before three's own
|
|
33
|
-
* the very frame that draws them.
|
|
26
|
+
* A move runs every stale rule at once — the cheap half — and starts a **sweep** of the writes: each
|
|
27
|
+
* frame drains the registry's write queue for at most {@link LAYOUT_BUDGET_MS}, visibility flips
|
|
28
|
+
* first and then defs inside the camera's frustum, and asks for another frame while any remain — so
|
|
29
|
+
* a plan of ten thousand labels re-writes over a handful of frames instead of stalling one, and what
|
|
30
|
+
* the user sees change lands first. The writes stay inside the frame callback, before three's own
|
|
31
|
+
* render, so they upload in the very frame that draws them.
|
|
34
32
|
*
|
|
35
33
|
* No React is involved in a re-layout. The handlers mutate def fields and the defs' own sync closures
|
|
36
34
|
* write them, so a zoom does not re-render a single component — which is what makes it affordable for
|
|
@@ -62,7 +60,6 @@ export function LayoutDriver(props) {
|
|
|
62
60
|
// The epsilon compares against the sweep's target size, so a camera that stopped mid-sweep does
|
|
63
61
|
// not re-mark the same sweep on every one of the frames that finish it.
|
|
64
62
|
if (pixelSize !== undefined && pixelSizeMoved(pixelSize, targets.pixelSize)) {
|
|
65
|
-
logger.debug(`pxToSvgScale ${+pixelSize.toFixed(3)}`);
|
|
66
63
|
targets.applyScale(pixelSize);
|
|
67
64
|
}
|
|
68
65
|
// Roll has no epsilon — the imperative renderer swept its roll handlers on any bearing change
|
|
@@ -5,67 +5,68 @@ import { type LayerDef, type RenderableDef } from '../types/defs';
|
|
|
5
5
|
* layout axes.
|
|
6
6
|
*
|
|
7
7
|
* Every def here declares its own `onScale` or `onRoll` — the registry never holds a handler, only
|
|
8
|
-
* the value each def's rule last ran at, per axis. One registry per renderer, reached through
|
|
9
|
-
* so two renderers showing the same defs at different zooms do not lay
|
|
8
|
+
* the value each def's rule last ran at, per axis. One registry per renderer, reached through
|
|
9
|
+
* {@link LayoutTargetsContext}, so two renderers showing the same defs at different zooms do not lay
|
|
10
|
+
* each other's out.
|
|
10
11
|
*
|
|
11
|
-
* A
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* A move splits in two. **The rules all run at once**, in the frame that noticed it: they are the
|
|
13
|
+
* cheap half — arithmetic over a def's own fields — and running them together is what lets every
|
|
14
|
+
* def's *decision*, a dot's "show me now" included, exist the moment the camera moves. **The writes
|
|
15
|
+
* are a sweep**: what the rules reported changed is queued and {@link drain} carries it to the GPU a
|
|
16
|
+
* frame's budget at a time — defs whose visibility flipped first, then the rest the camera can see,
|
|
17
|
+
* then the culled. The queue's shape is the legacy renderer's `UpdatesSystem` (fp-engine
|
|
18
|
+
* `core/updates.ts`); its unit is a def whose fields moved.
|
|
16
19
|
*/
|
|
17
20
|
export interface LayoutTargets {
|
|
18
21
|
/**
|
|
19
|
-
* The pixel size the registered defs are laid out at
|
|
20
|
-
*
|
|
21
|
-
* against so a camera that stopped moving does not re-mark the same sweep every frame.
|
|
22
|
+
* The pixel size the registered defs are laid out at, which is what the driver's epsilon compares
|
|
23
|
+
* against.
|
|
22
24
|
*/
|
|
23
25
|
readonly pixelSize: number;
|
|
24
26
|
/**
|
|
25
27
|
* The roll the registered defs are laid out at, in radians clockwise from north — the second
|
|
26
|
-
* layout axis, compared the way {@link pixelSize} is
|
|
27
|
-
* drained value.
|
|
28
|
+
* layout axis, compared the way {@link pixelSize} is.
|
|
28
29
|
*/
|
|
29
30
|
readonly roll: number;
|
|
30
|
-
/** Whether
|
|
31
|
+
/** Whether writes are still queued — what makes a caller keep frames coming. */
|
|
31
32
|
readonly hasWork: boolean;
|
|
32
33
|
/**
|
|
33
|
-
* Registers defs for re-layout, and lays out the ones that are not current — so a def
|
|
34
|
-
* from the moment it is registered, and a membership change re-lays out only what it
|
|
34
|
+
* Registers defs for re-layout, and lays out and writes the ones that are not current — so a def
|
|
35
|
+
* is current from the moment it is registered, and a membership change re-lays out only what it
|
|
36
|
+
* added.
|
|
35
37
|
* @param defs - The defs to lay out; every one of them declares an `onScale` or an `onRoll`.
|
|
36
38
|
* @returns An unregister function; it also drops whatever of these defs a sweep still queued.
|
|
37
39
|
*/
|
|
38
40
|
register(defs: readonly RenderableDef[]): () => void;
|
|
39
41
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
42
|
+
* Records the new pixel size and runs every registered scale rule against it, queueing what they
|
|
43
|
+
* report changed. Nothing is written here — that is {@link drain}'s job. Applying again before
|
|
44
|
+
* the queue drains re-runs the rules at the newer size, and a queued def is written once, with
|
|
45
|
+
* whatever its fields hold by then: the writes coalesce by construction.
|
|
44
46
|
* @param pixelSize - The new pixel size.
|
|
45
47
|
*/
|
|
46
48
|
applyScale(pixelSize: number): void;
|
|
47
49
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
+
* {@link applyScale}'s counterpart on the other axis, running only the rules that declare an
|
|
51
|
+
* `onRoll`. Both axes feed the one write queue.
|
|
50
52
|
* @param roll - The new roll, in radians clockwise from north.
|
|
51
53
|
*/
|
|
52
54
|
applyRoll(roll: number): void;
|
|
53
55
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
+
* Writes queued defs within a frame's budget, and says whether any remain — the caller's cue to
|
|
57
|
+
* ask for another frame.
|
|
56
58
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
59
|
+
* Three passes, in priority order: defs whose rule flipped them between hidden and shown, then
|
|
60
|
+
* every other queued def, then the ones an earlier pass deferred because the frustum rejects
|
|
61
|
+
* them. A flip matters most because it is what the user sees change — a label vanishing or a dot
|
|
62
|
+
* appearing — while a resize of something already shown can wait a frame.
|
|
61
63
|
*
|
|
62
|
-
* **A drain always takes at least one def**,
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* hidden defs are deferred.
|
|
64
|
+
* **A drain always takes at least one def**, however little budget is left — including none,
|
|
65
|
+
* which is what a worker descheduled before the first check leaves. That is what makes a sweep
|
|
66
|
+
* unable to stall: each drain shortens the queue, so the frames the driver keeps asking for
|
|
67
|
+
* eventually end it.
|
|
68
|
+
* @param frustum - The camera's frustum in plan space, when the caller has one; without it
|
|
69
|
+
* nothing is deferred.
|
|
69
70
|
* @param budgetMs - How much of the frame a call may spend. Non-positive means no limit.
|
|
70
71
|
* @returns Whether defs remain for a later drain.
|
|
71
72
|
*/
|
|
@@ -52,9 +52,9 @@ export function useLayoutTargets(layer, children) {
|
|
|
52
52
|
export function createLayoutTargets(invalidate, pixelSize, roll = 0) {
|
|
53
53
|
const groups = new Set();
|
|
54
54
|
/**
|
|
55
|
-
* The pixel size each def was last laid out at — the
|
|
56
|
-
*
|
|
57
|
-
*
|
|
55
|
+
* The pixel size each def was last laid out at — the registry's layout state, because the rule
|
|
56
|
+
* that lays a def out is the def's own. Keyed by def rather than by group, so a membership change
|
|
57
|
+
* re-lays out only the defs that joined.
|
|
58
58
|
*
|
|
59
59
|
* A def whose `onScale` is *replaced* after it was laid out is not noticed: the record says only
|
|
60
60
|
* "laid out at this size". Reassigning a handler is a thing nothing does — a def is built with its
|
|
@@ -63,21 +63,25 @@ export function createLayoutTargets(invalidate, pixelSize, roll = 0) {
|
|
|
63
63
|
const layouts = new WeakMap();
|
|
64
64
|
/**
|
|
65
65
|
* The roll each def's `onRoll` last ran at — the other axis's record, kept apart from
|
|
66
|
-
* {@link layouts} because the two move independently
|
|
67
|
-
* and a turn leaves every scale record current.
|
|
66
|
+
* {@link layouts} because the two move independently.
|
|
68
67
|
*/
|
|
69
68
|
const rolls = new WeakMap();
|
|
70
69
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* at drain time by the per-axis records above, so a def stale on both axes is dequeued once and
|
|
74
|
-
* laid out on both, and a new axis adds a record and a rule, never another queue. `deferred`
|
|
75
|
-
* holds the ones a drain pushed behind the visible — hidden defs, and defs the frustum
|
|
76
|
-
* rejected — worked off with whatever budget the stale pass leaves. Sets, not arrays, because one
|
|
77
|
-
* def can be re-marked while queued and must not be laid out twice.
|
|
70
|
+
* Whether each def was hidden when it was last written — what tells a visibility flip from a
|
|
71
|
+
* resize. Seeded at registration, which is when the def's instance wrote what it held.
|
|
78
72
|
*/
|
|
79
|
-
const
|
|
73
|
+
const drawnHidden = new WeakMap();
|
|
74
|
+
/**
|
|
75
|
+
* The write queue, in priority order: defs a rule flipped between hidden and shown, then every
|
|
76
|
+
* other def a rule changed, then the ones a drain found outside the frustum. Sets, not arrays,
|
|
77
|
+
* because a def re-reported before its write must still be written once — with whatever its
|
|
78
|
+
* fields hold by then.
|
|
79
|
+
*/
|
|
80
|
+
const flipped = new Set();
|
|
81
|
+
const queued = new Set();
|
|
80
82
|
const deferred = new Set();
|
|
83
|
+
/** What the sweep in flight has cost so far, for the log line that closes it. */
|
|
84
|
+
let sweep;
|
|
81
85
|
let current = pixelSize;
|
|
82
86
|
let currentRoll = roll;
|
|
83
87
|
/**
|
|
@@ -87,81 +91,90 @@ export function createLayoutTargets(invalidate, pixelSize, roll = 0) {
|
|
|
87
91
|
*/
|
|
88
92
|
const view = { pixelSize: current, roll: currentRoll };
|
|
89
93
|
/**
|
|
90
|
-
* Runs a def's
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* `update(def)` — so a handler mutating def fields needs no `update()` call of its own, and the two
|
|
94
|
-
* paths cannot disagree about what a def looks like. A def whose instance has not mounted yet syncs
|
|
95
|
-
* to nothing here and picks the mutation up when it does. A layer among the returned defs is
|
|
96
|
-
* reconciled instead, which is how a handler changes its own membership.
|
|
94
|
+
* Runs whatever of a def's rules are stale by their records, scale before roll so a rule that
|
|
95
|
+
* turns a label re-fits it at the size just applied. Each rule is recorded as run before anything
|
|
96
|
+
* it reported is handled, because handling can reach back in here.
|
|
97
97
|
*
|
|
98
|
-
* A
|
|
99
|
-
* the
|
|
100
|
-
* the handler over it again.
|
|
98
|
+
* A rule reporting nothing is still recorded as run, so a membership change does not run it over
|
|
99
|
+
* the def again — reporting nothing is how a rule skips the write, which is the half that costs.
|
|
101
100
|
* @param def - The def to lay out.
|
|
102
|
-
* @
|
|
101
|
+
* @param report - Receives what each rule reported changed.
|
|
103
102
|
*/
|
|
104
|
-
function
|
|
103
|
+
function layOutStale(def, report) {
|
|
105
104
|
// Hidden defs are laid out too. Whether a def draws at all can be exactly what its rule decides
|
|
106
105
|
// — a booth's dot appears only once the plan is far enough out for its label to stop fitting —
|
|
107
|
-
// so a registry that skipped them would never run the rule that would bring them back.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
106
|
+
// so a registry that skipped them would never run the rule that would bring them back.
|
|
107
|
+
if (def.onScale && layouts.get(def) !== current) {
|
|
108
|
+
const changed = def.onScale(view);
|
|
109
|
+
layouts.set(def, current);
|
|
110
|
+
if (changed?.length)
|
|
111
|
+
report(changed);
|
|
112
|
+
}
|
|
113
|
+
if (def.onRoll && rolls.get(def) !== currentRoll) {
|
|
114
|
+
const changed = def.onRoll(view);
|
|
115
|
+
rolls.set(def, currentRoll);
|
|
116
|
+
if (changed?.length)
|
|
117
|
+
report(changed);
|
|
118
|
+
}
|
|
119
119
|
}
|
|
120
120
|
/**
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* @param def - The def to
|
|
124
|
-
* @returns Whether anything was written.
|
|
121
|
+
* Writes a def now — the layer path or the slot path, per `writeChanged` — and records whether
|
|
122
|
+
* what it drew was hidden.
|
|
123
|
+
* @param def - The def to write.
|
|
125
124
|
*/
|
|
126
|
-
function
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const changed = def.onRoll(view);
|
|
130
|
-
rolls.set(def, currentRoll);
|
|
131
|
-
if (!changed || changed.length === 0)
|
|
132
|
-
return false;
|
|
133
|
-
writeChanged(changed);
|
|
134
|
-
return true;
|
|
125
|
+
function write(def) {
|
|
126
|
+
drawnHidden.set(def, def.hidden === true);
|
|
127
|
+
writeChanged([def]);
|
|
135
128
|
}
|
|
136
129
|
/**
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* @param
|
|
140
|
-
* @returns Whether anything was written.
|
|
130
|
+
* Queues what a rule reported changed. A layer is written at once: its change is a membership
|
|
131
|
+
* change, which only React reconciles, and React already defers the sweep behind it.
|
|
132
|
+
* @param changed - The defs the rule reported.
|
|
141
133
|
*/
|
|
142
|
-
function
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
134
|
+
function enqueue(changed) {
|
|
135
|
+
for (const def of changed) {
|
|
136
|
+
if (isLayerDef(def)) {
|
|
137
|
+
write(def);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const drawn = drawnHidden.get(def);
|
|
141
|
+
if (drawn !== undefined && drawn !== (def.hidden === true)) {
|
|
142
|
+
queued.delete(def);
|
|
143
|
+
deferred.delete(def);
|
|
144
|
+
flipped.add(def);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
// Flipped and flipped back before its write: against what the slot holds it is a resize.
|
|
148
|
+
flipped.delete(def);
|
|
149
|
+
deferred.delete(def);
|
|
150
|
+
queued.add(def);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
146
153
|
}
|
|
147
154
|
/**
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* @param carriesRule - Whether a def declares the moved axis's rule.
|
|
155
|
+
* Runs the stale rules of every registered def the moved axis concerns, queueing their writes.
|
|
156
|
+
* @param axis - The axis that moved, for the log line.
|
|
157
|
+
* @param handler - The moved axis's handler, `onScale` or `onRoll`.
|
|
152
158
|
*/
|
|
153
|
-
function
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
// Rule presence is read at marking time, so a rule cleared after registration — a hidden
|
|
158
|
-
// bookmark's — simply stops queueing, with nothing to unregister.
|
|
159
|
+
function runHandlers(axis, handler) {
|
|
160
|
+
const start = performance.now();
|
|
161
|
+
const before = flipped.size + queued.size + deferred.size;
|
|
162
|
+
let ran = 0;
|
|
159
163
|
for (const defs of groups) {
|
|
160
164
|
for (const def of defs) {
|
|
161
|
-
if (
|
|
162
|
-
|
|
165
|
+
if (!def[handler])
|
|
166
|
+
continue;
|
|
167
|
+
ran++;
|
|
168
|
+
layOutStale(def, enqueue);
|
|
163
169
|
}
|
|
164
170
|
}
|
|
171
|
+
const pending = flipped.size + queued.size + deferred.size;
|
|
172
|
+
if (pending > before) {
|
|
173
|
+
sweep ??= { defs: 0, drains: 0, writeMs: 0 };
|
|
174
|
+
sweep.defs += pending - before;
|
|
175
|
+
}
|
|
176
|
+
logger.debug(`${axis}: ran ${ran} ${handler} handlers in ${(performance.now() - start).toFixed(2)}ms, ` +
|
|
177
|
+
`${pending} defs to write (${flipped.size} visibility flips)`);
|
|
165
178
|
}
|
|
166
179
|
return {
|
|
167
180
|
get pixelSize() {
|
|
@@ -171,25 +184,33 @@ export function createLayoutTargets(invalidate, pixelSize, roll = 0) {
|
|
|
171
184
|
return currentRoll;
|
|
172
185
|
},
|
|
173
186
|
get hasWork() {
|
|
174
|
-
return
|
|
187
|
+
return flipped.size > 0 || queued.size > 0 || deferred.size > 0;
|
|
175
188
|
},
|
|
176
189
|
register(defs) {
|
|
177
190
|
groups.add(defs);
|
|
178
|
-
let
|
|
191
|
+
let wrote = false;
|
|
179
192
|
for (const def of defs) {
|
|
180
|
-
if (
|
|
181
|
-
|
|
193
|
+
if (!drawnHidden.has(def))
|
|
194
|
+
drawnHidden.set(def, def.hidden === true);
|
|
195
|
+
// Written at once, not queued: a def is current from the moment it registers, so one
|
|
196
|
+
// mounting mid-zoom draws at the size the plan is at.
|
|
197
|
+
layOutStale(def, (changed) => {
|
|
198
|
+
for (const each of changed)
|
|
199
|
+
write(each);
|
|
200
|
+
wrote = true;
|
|
201
|
+
});
|
|
182
202
|
}
|
|
183
203
|
// Registration happens in an effect rather than inside the frame loop, so anything written here
|
|
184
204
|
// needs a frame asking for it.
|
|
185
|
-
if (
|
|
205
|
+
if (wrote)
|
|
186
206
|
invalidate();
|
|
187
207
|
return () => {
|
|
188
208
|
groups.delete(defs);
|
|
189
|
-
// A queued def whose layer is gone must not be
|
|
190
|
-
// unmounting
|
|
209
|
+
// A queued def whose layer is gone must not be written from the backlog: its instances are
|
|
210
|
+
// unmounting.
|
|
191
211
|
for (const def of defs) {
|
|
192
|
-
|
|
212
|
+
flipped.delete(def);
|
|
213
|
+
queued.delete(def);
|
|
193
214
|
deferred.delete(def);
|
|
194
215
|
}
|
|
195
216
|
};
|
|
@@ -197,59 +218,58 @@ export function createLayoutTargets(invalidate, pixelSize, roll = 0) {
|
|
|
197
218
|
applyScale(nextPixelSize) {
|
|
198
219
|
current = nextPixelSize;
|
|
199
220
|
view.pixelSize = nextPixelSize;
|
|
200
|
-
|
|
221
|
+
runHandlers(`pxToSvgScale ${+nextPixelSize.toFixed(3)}`, 'onScale');
|
|
201
222
|
},
|
|
202
223
|
applyRoll(nextRoll) {
|
|
203
224
|
currentRoll = nextRoll;
|
|
204
225
|
view.roll = nextRoll;
|
|
205
|
-
|
|
226
|
+
runHandlers(`roll ${+nextRoll.toFixed(3)}`, 'onRoll');
|
|
206
227
|
},
|
|
207
228
|
drain(frustum, budgetMs = -1) {
|
|
208
|
-
if (
|
|
229
|
+
if (flipped.size === 0 && queued.size === 0 && deferred.size === 0)
|
|
209
230
|
return false;
|
|
210
231
|
const start = performance.now();
|
|
211
|
-
let wrote = 0;
|
|
212
232
|
/**
|
|
213
|
-
* Defs this drain has taken off a queue,
|
|
233
|
+
* Defs this drain has taken off a queue, written or deferred.
|
|
214
234
|
*
|
|
215
235
|
* It gates the budget so that the **first** def of a drain is always taken. Without that, a
|
|
216
236
|
* budget already spent before the loop began — the worker descheduled between `start` and the
|
|
217
237
|
* check, which a loaded machine does routinely — dequeues nothing at all, and a drain that
|
|
218
|
-
* dequeues nothing is the stall this budget exists to prevent.
|
|
219
|
-
* the guarantee is one def per drain rather than one per pass.
|
|
238
|
+
* dequeues nothing is the stall this budget exists to prevent.
|
|
220
239
|
*/
|
|
221
240
|
let processed = 0;
|
|
222
241
|
const overBudget = () => processed > 0 && budgetMs > 0 && performance.now() - start >= budgetMs;
|
|
223
|
-
// Sets iterate in insertion order and tolerate deletion mid-walk, which is what makes
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
242
|
+
// Sets iterate in insertion order and tolerate deletion mid-walk, which is what makes each pass
|
|
243
|
+
// a plain loop: a written def leaves its set before its write runs.
|
|
244
|
+
for (const queue of [flipped, queued]) {
|
|
245
|
+
for (const def of queue) {
|
|
246
|
+
if (overBudget())
|
|
247
|
+
break;
|
|
248
|
+
processed++;
|
|
249
|
+
queue.delete(def);
|
|
250
|
+
// Deferral is priority, never a skip: a def the camera cannot see joins the last pass.
|
|
251
|
+
if (frustum !== undefined && culledBy(def, frustum)) {
|
|
252
|
+
deferred.add(def);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
write(def);
|
|
237
256
|
}
|
|
238
|
-
if (layOutStale(def))
|
|
239
|
-
wrote++;
|
|
240
257
|
}
|
|
241
258
|
for (const def of deferred) {
|
|
242
259
|
if (overBudget())
|
|
243
260
|
break;
|
|
244
261
|
processed++;
|
|
245
262
|
deferred.delete(def);
|
|
246
|
-
|
|
247
|
-
wrote++;
|
|
263
|
+
write(def);
|
|
248
264
|
}
|
|
249
|
-
const remaining =
|
|
250
|
-
if (
|
|
251
|
-
|
|
252
|
-
|
|
265
|
+
const remaining = flipped.size + queued.size + deferred.size;
|
|
266
|
+
if (sweep) {
|
|
267
|
+
sweep.drains++;
|
|
268
|
+
sweep.writeMs += performance.now() - start;
|
|
269
|
+
if (remaining === 0) {
|
|
270
|
+
logger.debug(`wrote ${sweep.defs} defs over ${sweep.drains} frames in ${sweep.writeMs.toFixed(2)}ms`);
|
|
271
|
+
sweep = undefined;
|
|
272
|
+
}
|
|
253
273
|
}
|
|
254
274
|
return remaining > 0;
|
|
255
275
|
},
|
|
@@ -42,10 +42,20 @@ export declare function useSlot<R>(container: SlotContainer<R>, def: RenderableD
|
|
|
42
42
|
* never drawn, so computing its transform, color, or glyphs would be wasted; the update that shows
|
|
43
43
|
* it re-runs this sync, which writes the then-current state. Everything type-specific stays in
|
|
44
44
|
* `write`, which is what keeps this hook generic across every def type.
|
|
45
|
+
*
|
|
46
|
+
* **An `update(def)` renders nothing unless the slot's resource moved.** The only thing a render of
|
|
47
|
+
* an instance can do that a sync cannot is re-claim its slot, and that is needed only when the
|
|
48
|
+
* resource backing it changed — a text's line count, a shape's geometry. So the store's snapshot is
|
|
49
|
+
* the resource itself, not a version counter: an update that leaves it alone (a dim, a color, a
|
|
50
|
+
* move) is written by the subscription's own listener, straight into the slot, and React bails out
|
|
51
|
+
* of the render because the snapshot is unchanged. An update that moves it renders, and the render's
|
|
52
|
+
* re-claim writes the def into the new slot.
|
|
45
53
|
* @param container - The container holding the slot.
|
|
46
54
|
* @param def - The def this instance renders.
|
|
47
|
-
* @param
|
|
55
|
+
* @param resourceOf - Reads the per-instance resource backing the slot off the def; a new identity
|
|
56
|
+
* re-claims. Called on every update, so it must be cheap and return the same identity for an
|
|
57
|
+
* unchanged def. `null` for a container whose slots need no resource, which never re-claims.
|
|
48
58
|
* @param write - Writes the def's type-specific state into its slot. Called only while visible.
|
|
49
59
|
*/
|
|
50
|
-
export declare function useDefSync<R>(container: SlotContainer<R>, def: RenderableDef,
|
|
60
|
+
export declare function useDefSync<R>(container: SlotContainer<R>, def: RenderableDef, resourceOf: (() => R) | null, write: (slot: number) => void): void;
|
|
51
61
|
//# sourceMappingURL=hooks.d.ts.map
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useStore } from '@react-three/fiber';
|
|
2
2
|
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
3
3
|
import { registerHitResolver } from '../../events/pointer';
|
|
4
|
-
import { registerSlotWriter, subscribeDef, syncNow
|
|
5
|
-
import {
|
|
4
|
+
import { registerSlotWriter, subscribeDef, syncNow } from '../../update';
|
|
5
|
+
import { resolveDim, subscribeDim, useDimChain } from '../layer/layer-context';
|
|
6
6
|
/**
|
|
7
7
|
* Creates one layer's container and keeps it wired to its layer for as long as the component is
|
|
8
8
|
* mounted: the membership's paint order, how hits on its mesh resolve back to defs, and what runs
|
|
@@ -21,19 +21,37 @@ import { useLayerDim } from '../layer/layer-context';
|
|
|
21
21
|
*/
|
|
22
22
|
export function useContainer(create, defs, onKindMismatch) {
|
|
23
23
|
const [container] = useState(create);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
24
|
+
const chain = useDimChain();
|
|
25
|
+
const invalidate = useInvalidate();
|
|
26
|
+
// The membership the dim subscription below re-orders, current as of the last commit.
|
|
27
|
+
const defsRef = useRef(defs);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
defsRef.current = defs;
|
|
30
|
+
});
|
|
28
31
|
// Paint order for the whole membership, one pass per structural change. Child effects run before
|
|
29
32
|
// this one, so every instance the change mounted has already claimed its slot; defs whose
|
|
30
33
|
// instances bailed out (memo) never re-ran, which is fine — order lives in the container, not in
|
|
31
34
|
// their props. Running inside the same effect flush as the claims is what keeps a frame from
|
|
32
35
|
// drawing between a claim's default order and this pass.
|
|
33
36
|
useEffect(() => {
|
|
34
|
-
container.setPaintOrder(defs,
|
|
37
|
+
container.setPaintOrder(defs, resolveDim(chain) > 0);
|
|
35
38
|
invalidate();
|
|
36
|
-
}, [container, defs,
|
|
39
|
+
}, [container, defs, chain, invalidate]);
|
|
40
|
+
// And again as the layer's dim crosses zero — only whether it is dimmed, not by how much, since the
|
|
41
|
+
// order pass splits two ways. Followed by subscription, not render: an eased dim crosses zero once
|
|
42
|
+
// for every container in the plan in the same frame, and rendering each of them to get here cost
|
|
43
|
+
// that frame more than the passes themselves.
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
let dimmed = resolveDim(chain) > 0;
|
|
46
|
+
return subscribeDim(chain, () => {
|
|
47
|
+
const next = resolveDim(chain) > 0;
|
|
48
|
+
if (next === dimmed)
|
|
49
|
+
return;
|
|
50
|
+
dimmed = next;
|
|
51
|
+
container.setPaintOrder(defsRef.current, next);
|
|
52
|
+
invalidate();
|
|
53
|
+
});
|
|
54
|
+
}, [container, chain, invalidate]);
|
|
37
55
|
// How a hit on this container's mesh becomes a def. From an effect, so the mesh is bound by now;
|
|
38
56
|
// the container answers which field of an intersection names its slot, so nothing here has to know
|
|
39
57
|
// one mesh kind from another.
|
|
@@ -61,7 +79,7 @@ export function useContainer(create, defs, onKindMismatch) {
|
|
|
61
79
|
*/
|
|
62
80
|
export function useSlot(container, def, resource) {
|
|
63
81
|
const slotRef = useRef(-1);
|
|
64
|
-
const invalidate =
|
|
82
|
+
const invalidate = useInvalidate();
|
|
65
83
|
useEffect(() => {
|
|
66
84
|
const slot = container.claim(def, resource);
|
|
67
85
|
slotRef.current = slot;
|
|
@@ -91,15 +109,36 @@ export function useSlot(container, def, resource) {
|
|
|
91
109
|
* never drawn, so computing its transform, color, or glyphs would be wasted; the update that shows
|
|
92
110
|
* it re-runs this sync, which writes the then-current state. Everything type-specific stays in
|
|
93
111
|
* `write`, which is what keeps this hook generic across every def type.
|
|
112
|
+
*
|
|
113
|
+
* **An `update(def)` renders nothing unless the slot's resource moved.** The only thing a render of
|
|
114
|
+
* an instance can do that a sync cannot is re-claim its slot, and that is needed only when the
|
|
115
|
+
* resource backing it changed — a text's line count, a shape's geometry. So the store's snapshot is
|
|
116
|
+
* the resource itself, not a version counter: an update that leaves it alone (a dim, a color, a
|
|
117
|
+
* move) is written by the subscription's own listener, straight into the slot, and React bails out
|
|
118
|
+
* of the render because the snapshot is unchanged. An update that moves it renders, and the render's
|
|
119
|
+
* re-claim writes the def into the new slot.
|
|
94
120
|
* @param container - The container holding the slot.
|
|
95
121
|
* @param def - The def this instance renders.
|
|
96
|
-
* @param
|
|
122
|
+
* @param resourceOf - Reads the per-instance resource backing the slot off the def; a new identity
|
|
123
|
+
* re-claims. Called on every update, so it must be cheap and return the same identity for an
|
|
124
|
+
* unchanged def. `null` for a container whose slots need no resource, which never re-claims.
|
|
97
125
|
* @param write - Writes the def's type-specific state into its slot. Called only while visible.
|
|
98
126
|
*/
|
|
99
|
-
export function useDefSync(container, def,
|
|
127
|
+
export function useDefSync(container, def, resourceOf, write) {
|
|
128
|
+
// A container without resources is a `SlotContainer<null>`, so `null` is the resource it claims.
|
|
129
|
+
const readResource = (resourceOf ?? readNull);
|
|
130
|
+
const invalidate = useInvalidate();
|
|
131
|
+
// Refreshed after every render below; declared first because the subscription reaches for it.
|
|
132
|
+
const syncRef = useRef(noop);
|
|
133
|
+
const resource = useSyncExternalStore((listener) => subscribeDef(def, () => {
|
|
134
|
+
// This instance's own write rather than `syncNow`, which runs every instance drawing the
|
|
135
|
+
// def: each of them holds a subscription of its own, so `syncNow` here would write a def
|
|
136
|
+
// with N instances N² times.
|
|
137
|
+
syncRef.current();
|
|
138
|
+
invalidate();
|
|
139
|
+
listener();
|
|
140
|
+
}), readResource);
|
|
100
141
|
const slotRef = useSlot(container, def, resource);
|
|
101
|
-
const version = useSyncExternalStore((listener) => subscribeDef(def, listener), () => versionOf(def));
|
|
102
|
-
const invalidate = useThree((state) => state.invalidate);
|
|
103
142
|
/**
|
|
104
143
|
* Writes the def's current state into its slot: visibility always, the rest only when shown.
|
|
105
144
|
*/
|
|
@@ -107,6 +146,11 @@ export function useDefSync(container, def, resource, write) {
|
|
|
107
146
|
const slot = slotRef.current;
|
|
108
147
|
if (slot < 0)
|
|
109
148
|
return;
|
|
149
|
+
// The slot was claimed for the resource this render saw. When the def has since moved on to
|
|
150
|
+
// another — more lines than the claimed group holds — the write would overrun it; the render
|
|
151
|
+
// the change triggers re-claims and writes then.
|
|
152
|
+
if (!Object.is(readResource(), resource))
|
|
153
|
+
return;
|
|
110
154
|
const visible = !def.hidden;
|
|
111
155
|
container.setVisibleAt(slot, visible);
|
|
112
156
|
// Written even while hidden, unlike everything in `write`: it is one number, and writing it
|
|
@@ -118,16 +162,35 @@ export function useDefSync(container, def, resource, write) {
|
|
|
118
162
|
write(slot);
|
|
119
163
|
}
|
|
120
164
|
// Held in a ref and refreshed after every render: `sync` closes over a fresh `write` each time,
|
|
121
|
-
// and the effect below must not re-run just because that identity changed — only a
|
|
122
|
-
// means the
|
|
123
|
-
const syncRef = useRef(sync);
|
|
165
|
+
// and the effect below must not re-run just because that identity changed — only a new def or a
|
|
166
|
+
// new resource means the slot needs writing from here.
|
|
124
167
|
useEffect(() => {
|
|
125
168
|
syncRef.current = sync;
|
|
126
169
|
});
|
|
127
170
|
useEffect(() => registerSlotWriter(def, () => syncRef.current(), invalidate), [def, invalidate]);
|
|
128
|
-
//
|
|
129
|
-
//
|
|
171
|
+
// The first write, and the one after a re-claim. Declared after the ref refresh above, so it runs
|
|
172
|
+
// the closure that knows the new resource — `useSlot`'s own sync ran the previous render's, which
|
|
173
|
+
// the resource check turned into a no-op. Through the registry, so the frame request that shows it
|
|
174
|
+
// comes with it.
|
|
130
175
|
useEffect(() => {
|
|
131
176
|
syncNow(def);
|
|
132
|
-
}, [def,
|
|
177
|
+
}, [def, resource]);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* The renderer's frame request, without subscribing to R3F's store for it.
|
|
181
|
+
*
|
|
182
|
+
* `useThree(selector)` subscribes, and every instance holds this hook's result — so with a selector
|
|
183
|
+
* each R3F state change (a canvas re-render re-configures the root) ran one selector per instance,
|
|
184
|
+
* tens of thousands on a large plan, for a value that never changes. The store handle is context
|
|
185
|
+
* alone, and reading `invalidate` at call time also follows a host that replaces it (external mode).
|
|
186
|
+
* @returns A stable function requesting a frame.
|
|
187
|
+
*/
|
|
188
|
+
function useInvalidate() {
|
|
189
|
+
const store = useStore();
|
|
190
|
+
const [invalidate] = useState(() => () => store.getState().invalidate());
|
|
191
|
+
return invalidate;
|
|
133
192
|
}
|
|
193
|
+
// The resource of an instance whose container needs none.
|
|
194
|
+
const readNull = () => null;
|
|
195
|
+
// What an instance's sync ref holds before its first render has committed.
|
|
196
|
+
const noop = () => undefined;
|
|
@@ -20,7 +20,7 @@ import { shapeToMatrix, useDefSync, writeMatrixIfChanged } from '../containers';
|
|
|
20
20
|
*/
|
|
21
21
|
export const ImageInstance = memo(function ImageInstance(props) {
|
|
22
22
|
const { def, packed, container } = props;
|
|
23
|
-
useDefSync(container, def, packed, (slot) => {
|
|
23
|
+
useDefSync(container, def, () => packed, (slot) => {
|
|
24
24
|
const tile = tileRect(packed);
|
|
25
25
|
writeImageMatrix(container, slot, def, tile);
|
|
26
26
|
writeImageAnchor(container, slot, def, tile);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Suspense } from 'react';
|
|
2
|
+
import { memo, Suspense } from 'react';
|
|
3
3
|
import { useLayoutTargets } from '../../events/layout-targets';
|
|
4
4
|
import { usePointerProps } from '../../events/pointer';
|
|
5
5
|
import { isImageDefs, isLayerDefs, isLineDefs, isModelDefs, isShapeDefs, isTextDefs, } from '../../types/guards';
|
|
@@ -8,7 +8,7 @@ import { Lines } from '../lines';
|
|
|
8
8
|
import { Models } from '../models';
|
|
9
9
|
import { Shapes } from '../shapes';
|
|
10
10
|
import { Texts } from '../text';
|
|
11
|
-
import { LayerDimContext, LayerOverlayContext,
|
|
11
|
+
import { LayerDimContext, LayerOverlayContext, useDimChain, useLayerOverlay, } from './layer-context';
|
|
12
12
|
import { useStagedChildren } from './mount-scheduler';
|
|
13
13
|
import { useVisibilityGui } from './visibility-gui';
|
|
14
14
|
/**
|
|
@@ -26,7 +26,36 @@ import { useVisibilityGui } from './visibility-gui';
|
|
|
26
26
|
export function Layer(props) {
|
|
27
27
|
const { layer, children } = props;
|
|
28
28
|
useVisibilityGui(layer);
|
|
29
|
-
return (_jsx(
|
|
29
|
+
return (_jsx(MemoLayerNode, { layer: layer, hidden: false, orderBase: 0, orderStep: 1, children: children }));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The content of one leaf layer: the container for its defs' kind, inside a Suspense boundary of
|
|
33
|
+
* the leaf's own.
|
|
34
|
+
*
|
|
35
|
+
* The boundary means a content component that suspends — a loader hook, a lazily imported one —
|
|
36
|
+
* costs this leaf alone. Without it the nearest boundary is the one R3F's `<Canvas>` wraps
|
|
37
|
+
* everything in, and falling back to it hides *every* object in the scene (R3F's `hideInstance`
|
|
38
|
+
* writes `visible = false`) while the admission queue stalls forever, since the leaf never commits
|
|
39
|
+
* and so never reports mounted. With it, a suspended leaf behaves exactly like an empty one: the
|
|
40
|
+
* layer still commits, still reports, the next leaf is admitted on schedule, and the content mounts
|
|
41
|
+
* when it resolves.
|
|
42
|
+
* @param props - The leaf's defs.
|
|
43
|
+
* @returns The container for the defs' kind.
|
|
44
|
+
*/
|
|
45
|
+
function LeafContent(props) {
|
|
46
|
+
const { defs, mounted } = props;
|
|
47
|
+
let content = null;
|
|
48
|
+
if (isShapeDefs(defs))
|
|
49
|
+
content = _jsx(Shapes, { defs: defs, mounted: mounted });
|
|
50
|
+
else if (isImageDefs(defs))
|
|
51
|
+
content = _jsx(Images, { defs: defs, mounted: mounted });
|
|
52
|
+
else if (isTextDefs(defs))
|
|
53
|
+
content = _jsx(Texts, { defs: defs, mounted: mounted });
|
|
54
|
+
else if (isLineDefs(defs))
|
|
55
|
+
content = _jsx(Lines, { defs: defs, mounted: mounted });
|
|
56
|
+
else if (isModelDefs(defs))
|
|
57
|
+
content = _jsx(Models, { defs: defs, mounted: mounted });
|
|
58
|
+
return _jsx(Suspense, { fallback: null, children: content });
|
|
30
59
|
}
|
|
31
60
|
/**
|
|
32
61
|
* One layer of the tree as a three.js `<group>`, recursing into its child layers.
|
|
@@ -46,6 +75,11 @@ export function Layer(props) {
|
|
|
46
75
|
*
|
|
47
76
|
* Draw order is a pair: the tier, then the layer's own place. `overlay` adds a whole band above
|
|
48
77
|
* every plan-tier value. Array order still decides strictly within each band.
|
|
78
|
+
*
|
|
79
|
+
* **Dim is handed down as a source, and the node is rendered memoized.** An `update(layer)` renders this node
|
|
80
|
+
* alone: its child layers receive identical props, and the dim they inherit reaches its consumers
|
|
81
|
+
* through subscriptions to the layers themselves rather than a context value, so an eased dim — an update per frame
|
|
82
|
+
* on the root — renders one node instead of the whole tree.
|
|
49
83
|
* @param props - The node props.
|
|
50
84
|
* @returns The `<group>` for a layer def.
|
|
51
85
|
*/
|
|
@@ -53,16 +87,16 @@ function LayerNode(props) {
|
|
|
53
87
|
const { layer, children: prelude, hidden: parentHidden, orderBase, orderStep } = props;
|
|
54
88
|
// What the nearest ancestor already resolved for dim and overlay. A layer cannot see its
|
|
55
89
|
// ancestors any other way.
|
|
56
|
-
const
|
|
90
|
+
const parentChain = useDimChain();
|
|
57
91
|
const parentOverlay = useLayerOverlay();
|
|
58
92
|
// Once hidden or once in the overlay tier, a subtree stays that way. A descendant that dropped
|
|
59
93
|
// back into the plan tier would invert the nesting, and a descendant of a hidden layer is not on
|
|
60
94
|
// screen whatever it says about itself.
|
|
61
95
|
const hidden = parentHidden || layer.hidden === true;
|
|
62
96
|
const overlay = parentOverlay || layer.overlay === true;
|
|
63
|
-
// Nearest
|
|
64
|
-
//
|
|
65
|
-
const
|
|
97
|
+
// Nearest first, so the chain resolves to this layer's own dim when it declares one. Stable for a
|
|
98
|
+
// mounted layer, so the consumers below follow its dim by subscription, never by render.
|
|
99
|
+
const dimChain = [layer, ...parentChain];
|
|
66
100
|
// On screen only if nothing above hides it either. This is the value picking hangs off.
|
|
67
101
|
const visible = !hidden;
|
|
68
102
|
const { children, mounted } = useStagedChildren(layer);
|
|
@@ -71,7 +105,7 @@ function LayerNode(props) {
|
|
|
71
105
|
let contents = null;
|
|
72
106
|
if (isLayerDefs(children)) {
|
|
73
107
|
const childStep = orderStep / children.length;
|
|
74
|
-
contents = children.map((child, index) => (_jsx(
|
|
108
|
+
contents = children.map((child, index) => (_jsx(MemoLayerNode, { layer: child, hidden: hidden, orderBase: orderBase + index * childStep, orderStep: childStep }, child.name)));
|
|
75
109
|
}
|
|
76
110
|
else if (mounted > 0) {
|
|
77
111
|
contents = _jsx(LeafContent, { defs: children, mounted: mounted });
|
|
@@ -80,37 +114,13 @@ function LayerNode(props) {
|
|
|
80
114
|
// Always provided, never only when the layer declares one. Making a provider appear on first use
|
|
81
115
|
// would change this component's element type and remount the subtree, which releases and re-claims
|
|
82
116
|
// every GPU slot beneath it.
|
|
83
|
-
return (_jsx(LayerDimContext, { value:
|
|
117
|
+
return (_jsx(LayerDimContext, { value: dimChain, children: _jsx(LayerOverlayContext, { value: overlay, children: group }) }));
|
|
84
118
|
}
|
|
85
119
|
/**
|
|
86
|
-
*
|
|
87
|
-
* the
|
|
88
|
-
*
|
|
89
|
-
* The boundary means a content component that suspends — a loader hook, a lazily imported one —
|
|
90
|
-
* costs this leaf alone. Without it the nearest boundary is the one R3F's `<Canvas>` wraps
|
|
91
|
-
* everything in, and falling back to it hides *every* object in the scene (R3F's `hideInstance`
|
|
92
|
-
* writes `visible = false`) while the admission queue stalls forever, since the leaf never commits
|
|
93
|
-
* and so never reports mounted. With it, a suspended leaf behaves exactly like an empty one: the
|
|
94
|
-
* layer still commits, still reports, the next leaf is admitted on schedule, and the content mounts
|
|
95
|
-
* when it resolves.
|
|
96
|
-
* @param props - The leaf's defs.
|
|
97
|
-
* @returns The container for the defs' kind.
|
|
120
|
+
* {@link LayerNode}, memoized — what both the root and the recursion render, so an update renders
|
|
121
|
+
* the one node it names and its child layers bail out on equal props.
|
|
98
122
|
*/
|
|
99
|
-
|
|
100
|
-
const { defs, mounted } = props;
|
|
101
|
-
let content = null;
|
|
102
|
-
if (isShapeDefs(defs))
|
|
103
|
-
content = _jsx(Shapes, { defs: defs, mounted: mounted });
|
|
104
|
-
else if (isImageDefs(defs))
|
|
105
|
-
content = _jsx(Images, { defs: defs, mounted: mounted });
|
|
106
|
-
else if (isTextDefs(defs))
|
|
107
|
-
content = _jsx(Texts, { defs: defs, mounted: mounted });
|
|
108
|
-
else if (isLineDefs(defs))
|
|
109
|
-
content = _jsx(Lines, { defs: defs, mounted: mounted });
|
|
110
|
-
else if (isModelDefs(defs))
|
|
111
|
-
content = _jsx(Models, { defs: defs, mounted: mounted });
|
|
112
|
-
return _jsx(Suspense, { fallback: null, children: content });
|
|
113
|
-
}
|
|
123
|
+
const MemoLayerNode = memo(LayerNode);
|
|
114
124
|
/**
|
|
115
125
|
* Where the plan's own layers are numbered from. The tree subdivides `[0, 1)` depth first, so the
|
|
116
126
|
* mantissa stays free for nesting.
|
|
@@ -1,29 +1,62 @@
|
|
|
1
|
+
import { type LayerDef } from '../../types/defs';
|
|
1
2
|
/**
|
|
2
|
-
* The dim
|
|
3
|
-
*
|
|
3
|
+
* The layers whose `dim` decides a subtree's, nearest first: the enclosing layer, then each of its
|
|
4
|
+
* ancestors. Nearest declaration wins, and a layer's own `0` un-dims it inside a dimmed ancestor.
|
|
5
|
+
*
|
|
6
|
+
* The chain rather than the resolved amount, because the amount animates — floorplan eases the
|
|
7
|
+
* venue's dim over 300ms, an `update(layer)` per frame — and a number in context re-rendered every
|
|
8
|
+
* consumer and every layer between on each of those frames. A chain never changes for a mounted
|
|
9
|
+
* layer, so the tree stays still: consumers read it with {@link resolveDim} and follow it with
|
|
10
|
+
* {@link subscribeDim}, which is the def store's own notification.
|
|
11
|
+
*/
|
|
12
|
+
export type DimChain = readonly LayerDef[];
|
|
13
|
+
/**
|
|
14
|
+
* The dim amount a layer draws at, as a value, so the caller re-renders whenever it changes. A
|
|
15
|
+
* consumer that only writes the amount somewhere reads {@link useDimChain} and subscribes instead.
|
|
16
|
+
* @returns The nearest declared amount, or `0` with no layer above.
|
|
4
17
|
*/
|
|
5
18
|
export declare function useLayerDim(): number;
|
|
19
|
+
/**
|
|
20
|
+
* The enclosing layer's {@link DimChain}, for consumers that follow the dim without rendering: a
|
|
21
|
+
* material writing it into a uniform, a container re-running paint order when it crosses zero.
|
|
22
|
+
* @returns The chain, empty outside any layer.
|
|
23
|
+
*/
|
|
24
|
+
export declare function useDimChain(): DimChain;
|
|
6
25
|
/**
|
|
7
26
|
* Whether the current layer draws in the overlay tier.
|
|
8
27
|
* @returns `true` if this layer or an ancestor opted into the overlay tier, `false` otherwise.
|
|
9
28
|
*/
|
|
10
29
|
export declare function useLayerOverlay(): boolean;
|
|
11
30
|
/**
|
|
12
|
-
*
|
|
31
|
+
* Resolves a chain's dim amount.
|
|
32
|
+
* @param chain - The layers, nearest first.
|
|
33
|
+
* @returns The nearest declared dim, or `0` when none declares one.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveDim(chain: DimChain): number;
|
|
36
|
+
/**
|
|
37
|
+
* Follows every `update()` of a chain's layers — each one a possible dim change. The listener
|
|
38
|
+
* compares for itself; a membership update reaches it too and changes nothing.
|
|
39
|
+
* @param chain - The layers, nearest first.
|
|
40
|
+
* @param listener - Called after an update of any of them.
|
|
41
|
+
* @returns The unsubscribe function.
|
|
42
|
+
*/
|
|
43
|
+
export declare function subscribeDim(chain: DimChain, listener: () => void): () => void;
|
|
44
|
+
/**
|
|
45
|
+
* The {@link DimChain} the current layer hands down to its descendants.
|
|
13
46
|
*
|
|
14
47
|
* Context rather than a prop, because the value crosses the whole subtree and concerns none of the
|
|
15
48
|
* components between. A container hands its material nothing about layers, and a material that
|
|
16
49
|
* never asked about them still dims correctly. Threading it as a prop would put a `dim` on every
|
|
17
50
|
* material, every container, and every one of their inner components.
|
|
18
51
|
*
|
|
19
|
-
* Read through {@link useLayerDim} instead of directly. A `LayerNode` provides it, always, never
|
|
52
|
+
* Read through {@link useLayerDim} or {@link useDimChain} instead of directly. A `LayerNode` provides it, always, never
|
|
20
53
|
* only when it declares a `dim` of its own. See `Layer.tsx` for why.
|
|
21
54
|
*
|
|
22
|
-
* Defaults to `0`, undimmed, which is what "no layer above me" means for a component rendered
|
|
55
|
+
* Defaults to an empty chain, which reads `0`, undimmed, which is what "no layer above me" means for a component rendered
|
|
23
56
|
* outside a `Layer` tree. A fourth inherited field belongs in a context and a hook of its own here,
|
|
24
57
|
* not folded into this one.
|
|
25
58
|
*/
|
|
26
|
-
export declare const LayerDimContext: import("react").Context<
|
|
59
|
+
export declare const LayerDimContext: import("react").Context<DimChain>;
|
|
27
60
|
/**
|
|
28
61
|
* Whether the current layer hands its overlay tier down to its descendants.
|
|
29
62
|
*
|
|
@@ -1,9 +1,20 @@
|
|
|
1
|
-
import { createContext, use } from 'react';
|
|
1
|
+
import { createContext, use, useSyncExternalStore } from 'react';
|
|
2
|
+
import { subscribeDef } from '../../update';
|
|
2
3
|
/**
|
|
3
|
-
* The dim amount a layer draws at,
|
|
4
|
-
*
|
|
4
|
+
* The dim amount a layer draws at, as a value, so the caller re-renders whenever it changes. A
|
|
5
|
+
* consumer that only writes the amount somewhere reads {@link useDimChain} and subscribes instead.
|
|
6
|
+
* @returns The nearest declared amount, or `0` with no layer above.
|
|
5
7
|
*/
|
|
6
8
|
export function useLayerDim() {
|
|
9
|
+
const chain = use(LayerDimContext);
|
|
10
|
+
return useSyncExternalStore((listener) => subscribeDim(chain, listener), () => resolveDim(chain));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The enclosing layer's {@link DimChain}, for consumers that follow the dim without rendering: a
|
|
14
|
+
* material writing it into a uniform, a container re-running paint order when it crosses zero.
|
|
15
|
+
* @returns The chain, empty outside any layer.
|
|
16
|
+
*/
|
|
17
|
+
export function useDimChain() {
|
|
7
18
|
return use(LayerDimContext);
|
|
8
19
|
}
|
|
9
20
|
/**
|
|
@@ -14,21 +25,47 @@ export function useLayerOverlay() {
|
|
|
14
25
|
return use(LayerOverlayContext);
|
|
15
26
|
}
|
|
16
27
|
/**
|
|
17
|
-
*
|
|
28
|
+
* Resolves a chain's dim amount.
|
|
29
|
+
* @param chain - The layers, nearest first.
|
|
30
|
+
* @returns The nearest declared dim, or `0` when none declares one.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveDim(chain) {
|
|
33
|
+
for (const layer of chain) {
|
|
34
|
+
if (layer.dim !== undefined)
|
|
35
|
+
return layer.dim;
|
|
36
|
+
}
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Follows every `update()` of a chain's layers — each one a possible dim change. The listener
|
|
41
|
+
* compares for itself; a membership update reaches it too and changes nothing.
|
|
42
|
+
* @param chain - The layers, nearest first.
|
|
43
|
+
* @param listener - Called after an update of any of them.
|
|
44
|
+
* @returns The unsubscribe function.
|
|
45
|
+
*/
|
|
46
|
+
export function subscribeDim(chain, listener) {
|
|
47
|
+
const unsubscribes = chain.map((layer) => subscribeDef(layer, listener));
|
|
48
|
+
return () => {
|
|
49
|
+
for (const unsubscribe of unsubscribes)
|
|
50
|
+
unsubscribe();
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The {@link DimChain} the current layer hands down to its descendants.
|
|
18
55
|
*
|
|
19
56
|
* Context rather than a prop, because the value crosses the whole subtree and concerns none of the
|
|
20
57
|
* components between. A container hands its material nothing about layers, and a material that
|
|
21
58
|
* never asked about them still dims correctly. Threading it as a prop would put a `dim` on every
|
|
22
59
|
* material, every container, and every one of their inner components.
|
|
23
60
|
*
|
|
24
|
-
* Read through {@link useLayerDim} instead of directly. A `LayerNode` provides it, always, never
|
|
61
|
+
* Read through {@link useLayerDim} or {@link useDimChain} instead of directly. A `LayerNode` provides it, always, never
|
|
25
62
|
* only when it declares a `dim` of its own. See `Layer.tsx` for why.
|
|
26
63
|
*
|
|
27
|
-
* Defaults to `0`, undimmed, which is what "no layer above me" means for a component rendered
|
|
64
|
+
* Defaults to an empty chain, which reads `0`, undimmed, which is what "no layer above me" means for a component rendered
|
|
28
65
|
* outside a `Layer` tree. A fourth inherited field belongs in a context and a hook of its own here,
|
|
29
66
|
* not folded into this one.
|
|
30
67
|
*/
|
|
31
|
-
export const LayerDimContext = createContext(
|
|
68
|
+
export const LayerDimContext = createContext([]);
|
|
32
69
|
/**
|
|
33
70
|
* Whether the current layer hands its overlay tier down to its descendants.
|
|
34
71
|
*
|
|
@@ -8,7 +8,7 @@ import { type RenderableDef } from '../../../types/defs';
|
|
|
8
8
|
*/
|
|
9
9
|
export type DimPatch = (parameters: WebGLProgramParametersWithUniforms) => void;
|
|
10
10
|
/**
|
|
11
|
-
* The dim patch every material installs,
|
|
11
|
+
* The dim patch every material installs, following the layer amount from {@link useDimChain}.
|
|
12
12
|
*
|
|
13
13
|
* Dimming is deliberately **not** a material prop. It cuts across every material and every def type,
|
|
14
14
|
* so threading it as a prop would put a `dim` on each material, each container, and each of their
|
|
@@ -17,7 +17,8 @@ export type DimPatch = (parameters: WebGLProgramParametersWithUniforms) => void;
|
|
|
17
17
|
*
|
|
18
18
|
* The amount is held in one `IUniform` object per material and mutated in place, never replaced, so
|
|
19
19
|
* an animated dim — floorplan eases the venue's dim over 300ms — costs a number per material per
|
|
20
|
-
* frame with no recompile.
|
|
20
|
+
* frame with no recompile. The amount is followed by subscription, not read in render, so that frame
|
|
21
|
+
* renders nothing either.
|
|
21
22
|
* @returns The patch to call from the material's `onBeforeCompile`.
|
|
22
23
|
*/
|
|
23
24
|
export declare function useDimPatch(): DimPatch;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { useThree } from '@react-three/fiber';
|
|
2
2
|
import { useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { declareBeforeMain, sceneColorAnchor } from '../../../core/shader-source';
|
|
4
|
-
import {
|
|
4
|
+
import { resolveDim, subscribeDim, useDimChain } from '../../layer/layer-context';
|
|
5
5
|
/**
|
|
6
|
-
* The dim patch every material installs,
|
|
6
|
+
* The dim patch every material installs, following the layer amount from {@link useDimChain}.
|
|
7
7
|
*
|
|
8
8
|
* Dimming is deliberately **not** a material prop. It cuts across every material and every def type,
|
|
9
9
|
* so threading it as a prop would put a `dim` on each material, each container, and each of their
|
|
@@ -12,11 +12,12 @@ import { useLayerDim } from '../../layer/layer-context';
|
|
|
12
12
|
*
|
|
13
13
|
* The amount is held in one `IUniform` object per material and mutated in place, never replaced, so
|
|
14
14
|
* an animated dim — floorplan eases the venue's dim over 300ms — costs a number per material per
|
|
15
|
-
* frame with no recompile.
|
|
15
|
+
* frame with no recompile. The amount is followed by subscription, not read in render, so that frame
|
|
16
|
+
* renders nothing either.
|
|
16
17
|
* @returns The patch to call from the material's `onBeforeCompile`.
|
|
17
18
|
*/
|
|
18
19
|
export function useDimPatch() {
|
|
19
|
-
const
|
|
20
|
+
const chain = useDimChain();
|
|
20
21
|
const invalidate = useThree((state) => state.invalidate);
|
|
21
22
|
// A ref, not state: this object is written after render, every frame of an eased dim, and a ref is
|
|
22
23
|
// the container React sanctions that for. Its identity is what three holds onto, so the value can
|
|
@@ -25,11 +26,18 @@ export function useDimPatch() {
|
|
|
25
26
|
// Created once and kept: R3F writes every changed prop onto the material, so handing it a fresh
|
|
26
27
|
// handler each render risks a recompile per frame — the one thing an animated dim must not cost.
|
|
27
28
|
const [patch] = useState(() => (parameters) => applyDim(parameters, uniformRef.current));
|
|
28
|
-
const amount = clampDim(dim);
|
|
29
29
|
useEffect(() => {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
/** Writes the layer's current amount into the uniform, and asks for a frame when it moved. */
|
|
31
|
+
function follow() {
|
|
32
|
+
const amount = clampDim(resolveDim(chain));
|
|
33
|
+
if (uniformRef.current.value === amount)
|
|
34
|
+
return;
|
|
35
|
+
uniformRef.current.value = amount;
|
|
36
|
+
invalidate();
|
|
37
|
+
}
|
|
38
|
+
follow();
|
|
39
|
+
return subscribeDim(chain, follow);
|
|
40
|
+
}, [chain, invalidate]);
|
|
33
41
|
return patch;
|
|
34
42
|
}
|
|
35
43
|
/**
|
|
@@ -24,8 +24,7 @@ const logger = createLogger('shape-instance');
|
|
|
24
24
|
*/
|
|
25
25
|
export const ShapeInstance = memo(function ShapeInstance(props) {
|
|
26
26
|
const { def, container } = props;
|
|
27
|
-
|
|
28
|
-
useDefSync(container, def, geometry, (slot) => {
|
|
27
|
+
useDefSync(container, def, () => container.geometryFor(def.shape), (slot) => {
|
|
29
28
|
writeMatrixIfChanged(container.mesh, slot, shapeToMatrix(def.shape));
|
|
30
29
|
writeColorIfChanged(container.mesh, slot, def, logger);
|
|
31
30
|
// Checked on every sync rather than only behind a color write, because a def leaves its batch's
|
|
@@ -22,7 +22,7 @@ const logger = createLogger('text-instance');
|
|
|
22
22
|
*/
|
|
23
23
|
export const TextInstance = memo(function TextInstance(props) {
|
|
24
24
|
const { def, container } = props;
|
|
25
|
-
useDefSync(container, def, def.lines.length, (slot) => {
|
|
25
|
+
useDefSync(container, def, () => def.lines.length, (slot) => {
|
|
26
26
|
writeText(container, slot, def);
|
|
27
27
|
});
|
|
28
28
|
return null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@expofp/renderer",
|
|
3
|
-
"version": "3.39.
|
|
3
|
+
"version": "3.39.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/expofp/efp-app.git",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"troika-three-text": "^0.52.5",
|
|
43
43
|
"tslib": "^2.3.0",
|
|
44
44
|
"webgl-memory": "^1.1.3",
|
|
45
|
-
"@expofp/geometry": "3.39.
|
|
45
|
+
"@expofp/geometry": "3.39.1"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@react-three/test-renderer": "^9.1.0",
|