@expofp/renderer 3.39.0 → 3.40.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.
@@ -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 starts a **sweep**, never a synchronous pass: each frame drains the registry's backlog for
18
- * at most {@link LAYOUT_BUDGET_MS}, defs inside the camera's frustum first, and asks for another
19
- * frame while any backlog remains — so a plan of ten thousand labels re-lays out over a handful of
20
- * frames instead of stalling one, and what the user is looking at is what lands first. The layouts
21
- * stay inside the frame callback, before three's own render, so their attribute writes upload in
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 running layout rules, in milliseconds — the same budget
14
- * the legacy renderer's `UpdatesSystem` drained its update queue with. Small enough to leave a
15
- * 60fps frame its render; the backlog it leaves is carried into the next frame, visible defs first.
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 starts a **sweep**, never a synchronous pass: each frame drains the registry's backlog for
29
- * at most {@link LAYOUT_BUDGET_MS}, defs inside the camera's frustum first, and asks for another
30
- * frame while any backlog remains — so a plan of ten thousand labels re-lays out over a handful of
31
- * frames instead of stalling one, and what the user is looking at is what lands first. The layouts
32
- * stay inside the frame callback, before three's own render, so their attribute writes upload in
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 {@link LayoutTargetsContext},
9
- * so two renderers showing the same defs at different zooms do not lay each other's out.
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 pixel-size move is a **sweep**, not a synchronous pass: {@link applyScale} marks every registered def
12
- * stale and {@link drain} works the backlog off a frame's budget at a time, defs the camera can see
13
- * first. The shape is the legacy renderer's `UpdatesSystem` (fp-engine `core/updates.ts`), whose
14
- * pending/deferred queues and per-frame time budget this registry inherits — with the queue's unit
15
- * changed from "def whose fields moved" to "def whose layout rule must re-run".
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 — from the moment {@link applyScale} marks a
20
- * sweep, the size defs are being *brought* to, which is what the driver's epsilon must compare
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: against the sweep's target, not the last
27
- * drained value.
28
+ * layout axis, compared the way {@link pixelSize} is.
28
29
  */
29
30
  readonly roll: number;
30
- /** Whether a sweep still has defs to lay out — what makes a caller keep frames coming. */
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 is current
34
- * from the moment it is registered, and a membership change re-lays out only what it added.
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
- * Starts a sweep: records the new pixel size and marks every registered def stale. Nothing is
41
- * laid out here — that is {@link drain}'s job — so the frame that noticed the move pays only the
42
- * marking. Applying again mid-sweep re-marks at the newer size, and a def the earlier sweep had
43
- * not reached is simply laid out once, at that newer size: the backlog coalesces by construction.
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
- * Starts a roll sweep — {@link applyScale}'s counterpart on the other axis, marking only the defs that
49
- * declare an `onRoll`. The two axes coalesce independently, and one {@link drain} works both off.
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
- * Works off the sweep within a frame's budget, and says whether any backlog remains — the
55
- * caller's cue to ask for another frame.
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
- * Two passes, in the legacy `UpdatesSystem`'s shape: the stale defs first, except that a hidden
58
- * def or one the frustum rejects is *deferred* rather than laid out; then the deferred ones, with
59
- * whatever budget is left. Priority is all deferral buys — every def is still laid out before the
60
- * sweep ends, because whether a def draws can be exactly what its rule decides.
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**, across the two passes together, however little
63
- * budget is left — including none, which is what a worker descheduled before the first check
64
- * leaves. That is what makes a sweep unable to stall: each drain shortens the backlog, so the
65
- * frames the driver keeps asking for eventually end it. Without the guarantee a drain can dequeue
66
- * nothing, return "more to do", and be asked again on the next frame with the same result.
67
- * @param frustum - The camera's frustum in plan space, when the caller has one; without it only
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 whole of the registry's state, because the
56
- * rule that lays a def out is the def's own. Keyed by def rather than by group, so a membership
57
- * change re-lays out only the defs that joined.
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: a zoom leaves every roll record current,
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
- * The sweep's backlog: defs an `apply` marked stale and {@link LayoutTargets.drain} has not
72
- * reached. **One queue for every axis** — which of a queued def's rules actually runs is decided
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 pending = new Set();
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 handler and writes the result.
91
- *
92
- * A leaf goes through the def's own registered sync — the same closure the React path runs after
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 handler reporting nothing skips that write, which is the whole point of the return value: it is
99
- * the half that costs. The def is still recorded as laid out, so a membership change does not run
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
- * @returns Whether anything was written.
101
+ * @param report - Receives what each rule reported changed.
103
102
  */
104
- function layOut(def) {
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. Skipping
108
- // the *waste* is the rule's own job instead: a handler with nothing worth drawing returns
109
- // nothing, and the slot write is the half that costs.
110
- if (!def.onScale || layouts.get(def) === current)
111
- return false;
112
- const changed = def.onScale(view);
113
- // Recorded before the write, which runs the def's own sync and can reach back in here.
114
- layouts.set(def, current);
115
- if (!changed || changed.length === 0)
116
- return false;
117
- writeChanged(changed);
118
- return true;
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
- * Runs a def's roll rule and writes the result — {@link layOut} on the other axis, with the same
122
- * record-before-write ordering and the same "an empty return skips the write" contract.
123
- * @param def - The def to lay out.
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 layOutRoll(def) {
127
- if (!def.onRoll || rolls.get(def) === currentRoll)
128
- return false;
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
- * Runs whatever of a def's rules are stale by their records — the one unit of work a dequeued def
138
- * costs, scale before roll so a rule that turns a label re-fits it at the size just applied.
139
- * @param def - The def to lay out.
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 layOutStale(def) {
143
- const scaled = layOut(def);
144
- const rolled = layOutRoll(def);
145
- return scaled || rolled;
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
- * Marks stale defs for the sweep: the previous sweep's deferrals rejoin the queue — where the new
149
- * frustum decides their priority afresh, and cross-axis work survives whichever axis moved — and
150
- * every def carrying the moved axis's rule is (re-)queued.
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 requeue(carriesRule) {
154
- for (const def of deferred)
155
- pending.add(def);
156
- deferred.clear();
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 (carriesRule(def))
162
- pending.add(def);
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 pending.size > 0 || deferred.size > 0;
187
+ return flipped.size > 0 || queued.size > 0 || deferred.size > 0;
175
188
  },
176
189
  register(defs) {
177
190
  groups.add(defs);
178
- let laidOut = false;
191
+ let wrote = false;
179
192
  for (const def of defs) {
180
- if (layOutStale(def))
181
- laidOut = true;
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 (laidOut)
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 laid out from the backlog: its instances are
190
- // unmounting, and its handler may read state its owner already tore down.
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
- pending.delete(def);
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
- requeue((def) => def.onScale !== undefined);
221
+ runHandlers(`pxToSvgScale ${+nextPixelSize.toFixed(3)}`, 'onScale');
201
222
  },
202
223
  applyRoll(nextRoll) {
203
224
  currentRoll = nextRoll;
204
225
  view.roll = nextRoll;
205
- requeue((def) => def.onRoll !== undefined);
226
+ runHandlers(`roll ${+nextRoll.toFixed(3)}`, 'onRoll');
206
227
  },
207
228
  drain(frustum, budgetMs = -1) {
208
- if (pending.size === 0 && deferred.size === 0)
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, laid out or deferred.
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. Shared across both passes, so
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 the two
224
- // passes below plain loops: the order moves arrived is the priority within a pass, and a
225
- // processed def leaves its set before its rules run.
226
- for (const def of pending) {
227
- if (overBudget())
228
- break;
229
- processed++;
230
- pending.delete(def);
231
- // Deferral is priority, never a skip: a hidden def, or one the camera cannot see, joins the
232
- // second pass rather than being laid out now. `def.hidden` needs no frustum — hidden is not
233
- // drawn wherever it sits.
234
- if (def.hidden === true || (frustum !== undefined && culledBy(def, frustum))) {
235
- deferred.add(def);
236
- continue;
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
- if (layOutStale(def))
247
- wrote++;
263
+ write(def);
248
264
  }
249
- const remaining = pending.size + deferred.size;
250
- if (wrote > 0) {
251
- const took = (performance.now() - start).toFixed(2);
252
- logger.debug(`laid out ${wrote} defs (${remaining} remaining) in ${took}ms`);
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 resource - The per-instance resource backing the slot; a new identity re-claims.
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, resource: R, write: (slot: number) => void): void;
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 { useThree } from '@react-three/fiber';
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, versionOf } from '../../update';
5
- import { useLayerDim } from '../layer/layer-context';
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
- // Only whether the layer is dimmed, not by how much: the order pass splits two ways, so an eased
25
- // dim re-runs it as its value crosses zero rather than on every frame of the ease.
26
- const layerDimmed = useLayerDim() > 0;
27
- const invalidate = useThree((state) => state.invalidate);
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, layerDimmed);
37
+ container.setPaintOrder(defs, resolveDim(chain) > 0);
35
38
  invalidate();
36
- }, [container, defs, layerDimmed, invalidate]);
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 = useThree((state) => state.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 resource - The per-instance resource backing the slot; a new identity re-claims.
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, resource, write) {
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 version bump
122
- // means the def actually moved.
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
- // Through the registry rather than the ref, so the React path runs the very same write every other
129
- // path does — the frame request that shows it included.
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, version]);
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, useLayerDim, useLayerOverlay, } from './layer-context';
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(LayerNode, { layer: layer, hidden: false, orderBase: 0, orderStep: 1, children: children }));
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 parentDim = useLayerDim();
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 declaration wins, so only `undefined` inherits. A layer's own 0 un-dims it inside a
64
- // dimmed ancestor.
65
- const dim = layer.dim ?? parentDim;
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(LayerNode, { layer: child, hidden: hidden, orderBase: orderBase + index * childStep, orderStep: childStep }, child.name)));
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: dim, children: _jsx(LayerOverlayContext, { value: overlay, children: group }) }));
117
+ return (_jsx(LayerDimContext, { value: dimChain, children: _jsx(LayerOverlayContext, { value: overlay, children: group }) }));
84
118
  }
85
119
  /**
86
- * The content of one leaf layer: the container for its defs' kind, inside a Suspense boundary of
87
- * the leaf's own.
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
- function LeafContent(props) {
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 amount a layer draws at, which every material beneath it reads.
3
- * @returns The nearest provided amount, or `0` with no layer above.
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
- * The dim amount the current layer hands down to its descendants.
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<number>;
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, which every material beneath it reads.
4
- * @returns The nearest provided amount, or `0` with no layer above.
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
- * The dim amount the current layer hands down to its descendants.
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(0);
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, reading the layer amount from {@link useLayerDim}.
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 { useLayerDim } from '../../layer/layer-context';
4
+ import { resolveDim, subscribeDim, useDimChain } from '../../layer/layer-context';
5
5
  /**
6
- * The dim patch every material installs, reading the layer amount from {@link useLayerDim}.
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 dim = useLayerDim();
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
- uniformRef.current.value = amount;
31
- invalidate();
32
- }, [amount, invalidate]);
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
- const geometry = container.geometryFor(def.shape);
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.0",
3
+ "version": "3.40.0",
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.0"
45
+ "@expofp/geometry": "3.40.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@react-three/test-renderer": "^9.1.0",