@expofp/renderer 3.38.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.
Files changed (64) hide show
  1. package/dist/controls/camera-api.d.ts +28 -0
  2. package/dist/controls/camera-api.js +33 -10
  3. package/dist/controls/hooks.js +2 -0
  4. package/dist/core/InstanceUniforms.d.ts +15 -0
  5. package/dist/core/InstanceUniforms.js +32 -0
  6. package/dist/core/batch/BatchedMesh.js +6 -0
  7. package/dist/core/instanced/instanced-material.js +5 -1
  8. package/dist/core/lines/BatchedLines.d.ts +9 -7
  9. package/dist/core/lines/BatchedLines.js +9 -7
  10. package/dist/core/lines/{FrustumCulling.d.ts → FrustumCullingLines.d.ts} +7 -6
  11. package/dist/core/lines/{FrustumCulling.js → FrustumCullingLines.js} +2 -2
  12. package/dist/core/lines/{Raycasting.d.ts → RaycastingLines.d.ts} +7 -6
  13. package/dist/core/lines/{Raycasting.js → RaycastingLines.js} +2 -2
  14. package/dist/core/lines/lines-material.js +10 -1
  15. package/dist/core/sprites/BatchedSprites.d.ts +115 -0
  16. package/dist/core/sprites/BatchedSprites.js +205 -0
  17. package/dist/core/sprites/RaycastingSprites.d.ts +41 -0
  18. package/dist/core/sprites/RaycastingSprites.js +71 -0
  19. package/dist/core/sprites/sprite-material.d.ts +59 -0
  20. package/dist/core/sprites/sprite-material.js +192 -0
  21. package/dist/core/text/BatchedText.d.ts +6 -0
  22. package/dist/core/text/BatchedText.js +6 -0
  23. package/dist/core/text/MemberTexture.js +55 -1
  24. package/dist/core/text/Text.d.ts +16 -1
  25. package/dist/core/text/Text.js +15 -0
  26. package/dist/core/text/batched-text-material.js +4 -2
  27. package/dist/events/LayoutDriver.d.ts +6 -6
  28. package/dist/events/LayoutDriver.js +9 -12
  29. package/dist/events/layout-targets.d.ts +35 -34
  30. package/dist/events/layout-targets.js +127 -107
  31. package/dist/objects/containers/hooks.d.ts +19 -4
  32. package/dist/objects/containers/hooks.js +101 -23
  33. package/dist/objects/containers/mesh-writes.js +3 -3
  34. package/dist/objects/images/ImageInstance.js +104 -36
  35. package/dist/objects/images/Images.d.ts +8 -7
  36. package/dist/objects/images/Images.js +94 -28
  37. package/dist/objects/images/image-container.d.ts +14 -3
  38. package/dist/objects/images/image-container.js +19 -5
  39. package/dist/objects/layer/Layer.d.ts +7 -6
  40. package/dist/objects/layer/Layer.js +77 -57
  41. package/dist/objects/layer/layer-context.d.ts +74 -0
  42. package/dist/objects/layer/layer-context.js +82 -0
  43. package/dist/objects/materials/ColorMaterial.js +16 -3
  44. package/dist/objects/materials/LineMaterial.js +4 -2
  45. package/dist/objects/materials/TextureMaterial.js +4 -2
  46. package/dist/objects/materials/effects/color-dim.d.ts +3 -11
  47. package/dist/objects/materials/effects/color-dim.js +17 -17
  48. package/dist/objects/materials/effects/effects-context.d.ts +6 -5
  49. package/dist/objects/materials/shared.d.ts +18 -0
  50. package/dist/objects/materials/shared.js +21 -0
  51. package/dist/objects/models/Models.js +1 -8
  52. package/dist/objects/shapes/ShapeInstance.js +1 -2
  53. package/dist/objects/shapes/Shapes.js +2 -9
  54. package/dist/objects/text/TextInstance.js +2 -2
  55. package/dist/objects/text/text-layout.d.ts +13 -4
  56. package/dist/objects/text/text-layout.js +30 -4
  57. package/dist/space/Space.d.ts +15 -0
  58. package/dist/space/Space.js +16 -10
  59. package/dist/types/defs.d.ts +37 -0
  60. package/dist/utils/logging.d.ts +6 -3
  61. package/dist/utils/logging.js +15 -7
  62. package/dist/utils/math.d.ts +11 -0
  63. package/dist/utils/math.js +14 -0
  64. package/package.json +2 -2
@@ -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
  },
@@ -3,16 +3,21 @@ import { type RenderableDef } from '../../types/defs';
3
3
  import { type SlotContainer, type SlotMesh } from './SlotContainer';
4
4
  /**
5
5
  * Creates one layer's container and keeps it wired to its layer for as long as the component is
6
- * mounted: the membership's paint order, and how hits on its mesh resolve back to defs.
6
+ * mounted: the membership's paint order, how hits on its mesh resolve back to defs, and what runs
7
+ * when a member no longer belongs in this container's kind.
7
8
  *
8
9
  * The container is built once, from the first render's defs, and outlives every render after —
9
10
  * membership changes grow it in place rather than rebuilding it, which is what lets the mesh R3F
10
11
  * built from its args stand too. `create` is therefore called once and its later identities ignored.
11
12
  * @param create - Builds the container. Called on the first render only.
12
13
  * @param defs - The layer's defs, in authored paint order.
14
+ * @param onKindMismatch - Re-splits the layer, for a container that finds a member of the wrong
15
+ * kind. Registered as {@link SlotContainer.setKindMismatchHandler} and cleared on unmount or
16
+ * before a new identity replaces it. A container with no kind of its own to leave omits it, and
17
+ * nothing is registered.
13
18
  * @returns The container.
14
19
  */
15
- export declare function useContainer<T extends RenderableDef, C extends SlotContainer<unknown, SlotMesh, T>>(create: () => C, defs: readonly T[]): C;
20
+ export declare function useContainer<T extends RenderableDef, C extends SlotContainer<unknown, SlotMesh, T>>(create: () => C, defs: readonly T[], onKindMismatch?: () => void): C;
16
21
  /**
17
22
  * Claims a slot for one instance, releasing on unmount. Paint order is not this hook's concern —
18
23
  * the container writes it for the whole sibling list in one pass (see {@link useContainer}).
@@ -37,10 +42,20 @@ export declare function useSlot<R>(container: SlotContainer<R>, def: RenderableD
37
42
  * never drawn, so computing its transform, color, or glyphs would be wasted; the update that shows
38
43
  * it re-runs this sync, which writes the then-current state. Everything type-specific stays in
39
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.
40
53
  * @param container - The container holding the slot.
41
54
  * @param def - The def this instance renders.
42
- * @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.
43
58
  * @param write - Writes the def's type-specific state into its slot. Called only while visible.
44
59
  */
45
- 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;
46
61
  //# sourceMappingURL=hooks.d.ts.map
@@ -1,38 +1,71 @@
1
- import { useThree } from '@react-three/fiber';
2
- import { use, useEffect, useRef, useState, useSyncExternalStore } from 'react';
1
+ import { useStore } from '@react-three/fiber';
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 { LayerDimContext } from '../materials/effects/color-dim';
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
- * mounted: the membership's paint order, and how hits on its mesh resolve back to defs.
8
+ * mounted: the membership's paint order, how hits on its mesh resolve back to defs, and what runs
9
+ * when a member no longer belongs in this container's kind.
9
10
  *
10
11
  * The container is built once, from the first render's defs, and outlives every render after —
11
12
  * membership changes grow it in place rather than rebuilding it, which is what lets the mesh R3F
12
13
  * built from its args stand too. `create` is therefore called once and its later identities ignored.
13
14
  * @param create - Builds the container. Called on the first render only.
14
15
  * @param defs - The layer's defs, in authored paint order.
16
+ * @param onKindMismatch - Re-splits the layer, for a container that finds a member of the wrong
17
+ * kind. Registered as {@link SlotContainer.setKindMismatchHandler} and cleared on unmount or
18
+ * before a new identity replaces it. A container with no kind of its own to leave omits it, and
19
+ * nothing is registered.
15
20
  * @returns The container.
16
21
  */
17
- export function useContainer(create, defs) {
22
+ export function useContainer(create, defs, onKindMismatch) {
18
23
  const [container] = useState(create);
19
- // Only whether the layer is dimmed, not by how much: the order pass splits two ways, so an eased
20
- // dim re-runs it as its value crosses zero rather than on every frame of the ease.
21
- const layerDimmed = use(LayerDimContext) > 0;
22
- 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
+ });
23
31
  // Paint order for the whole membership, one pass per structural change. Child effects run before
24
32
  // this one, so every instance the change mounted has already claimed its slot; defs whose
25
33
  // instances bailed out (memo) never re-ran, which is fine — order lives in the container, not in
26
34
  // their props. Running inside the same effect flush as the claims is what keeps a frame from
27
35
  // drawing between a claim's default order and this pass.
28
36
  useEffect(() => {
29
- container.setPaintOrder(defs, layerDimmed);
37
+ container.setPaintOrder(defs, resolveDim(chain) > 0);
30
38
  invalidate();
31
- }, [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]);
32
55
  // How a hit on this container's mesh becomes a def. From an effect, so the mesh is bound by now;
33
56
  // the container answers which field of an intersection names its slot, so nothing here has to know
34
57
  // one mesh kind from another.
35
58
  useEffect(() => registerHitResolver(container.mesh, (hit) => container.defFromHit(hit)), [container]);
59
+ // Assigned after mount rather than passed to `create`, because the container is built once and
60
+ // outlives every render, so it cannot close over a fresh callback. A caller with nothing to run on
61
+ // a mismatch passes no handler, and this then does nothing: no call into the container and no
62
+ // cleanup closure either.
63
+ useEffect(() => {
64
+ if (!onKindMismatch)
65
+ return;
66
+ container.setKindMismatchHandler(onKindMismatch);
67
+ return () => container.setKindMismatchHandler(undefined);
68
+ }, [container, onKindMismatch]);
36
69
  return container;
37
70
  }
38
71
  /**
@@ -46,7 +79,7 @@ export function useContainer(create, defs) {
46
79
  */
47
80
  export function useSlot(container, def, resource) {
48
81
  const slotRef = useRef(-1);
49
- const invalidate = useThree((state) => state.invalidate);
82
+ const invalidate = useInvalidate();
50
83
  useEffect(() => {
51
84
  const slot = container.claim(def, resource);
52
85
  slotRef.current = slot;
@@ -76,15 +109,36 @@ export function useSlot(container, def, resource) {
76
109
  * never drawn, so computing its transform, color, or glyphs would be wasted; the update that shows
77
110
  * it re-runs this sync, which writes the then-current state. Everything type-specific stays in
78
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.
79
120
  * @param container - The container holding the slot.
80
121
  * @param def - The def this instance renders.
81
- * @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.
82
125
  * @param write - Writes the def's type-specific state into its slot. Called only while visible.
83
126
  */
84
- 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);
85
141
  const slotRef = useSlot(container, def, resource);
86
- const version = useSyncExternalStore((listener) => subscribeDef(def, listener), () => versionOf(def));
87
- const invalidate = useThree((state) => state.invalidate);
88
142
  /**
89
143
  * Writes the def's current state into its slot: visibility always, the rest only when shown.
90
144
  */
@@ -92,6 +146,11 @@ export function useDefSync(container, def, resource, write) {
92
146
  const slot = slotRef.current;
93
147
  if (slot < 0)
94
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;
95
154
  const visible = !def.hidden;
96
155
  container.setVisibleAt(slot, visible);
97
156
  // Written even while hidden, unlike everything in `write`: it is one number, and writing it
@@ -103,16 +162,35 @@ export function useDefSync(container, def, resource, write) {
103
162
  write(slot);
104
163
  }
105
164
  // Held in a ref and refreshed after every render: `sync` closes over a fresh `write` each time,
106
- // and the effect below must not re-run just because that identity changed — only a version bump
107
- // means the def actually moved.
108
- 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.
109
167
  useEffect(() => {
110
168
  syncRef.current = sync;
111
169
  });
112
170
  useEffect(() => registerSlotWriter(def, () => syncRef.current(), invalidate), [def, invalidate]);
113
- // Through the registry rather than the ref, so the React path runs the very same write every other
114
- // 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.
115
175
  useEffect(() => {
116
176
  syncNow(def);
117
- }, [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;
118
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;
@@ -82,9 +82,9 @@ export function writeColorIfChanged(mesh, slot, def, logger) {
82
82
  mesh.setColorAt(slot, _colorWithAlpha);
83
83
  return true;
84
84
  }
85
- // Scratch shared by every write: each is filled and consumed within a single synchronous
86
- // statement, so a write costs buffer uploads and no allocation. `_matrix` is the one callers see
87
- // (through `shapeToMatrix`); the `_previous*` pair is only ever read back and compared inside the
85
+ // Scratch shared by every write. Each is filled and consumed within a single synchronous
86
+ // statement, so a write costs buffer uploads and no allocation. `_matrix` is the one callers see,
87
+ // through `shapeToMatrix`. The `_previous*` values are only ever read back and compared inside the
88
88
  // write helpers, so a caller still holding `_matrix` never collides with them.
89
89
  const _position = new Vector3();
90
90
  const _quaternion = new Quaternion();