@expofp/renderer 3.34.2 → 3.35.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 (88) hide show
  1. package/dist/Renderer.d.ts +14 -0
  2. package/dist/Renderer.js +16 -4
  3. package/dist/core/SquareDataTexture.d.ts +2 -2
  4. package/dist/core/SquareDataTexture.js +2 -2
  5. package/dist/core/batch/BatchedMesh.d.ts +3 -3
  6. package/dist/core/batch/BatchedMesh.js +3 -3
  7. package/dist/core/instanced/InstancedMesh.d.ts +7 -7
  8. package/dist/core/instanced/InstancedMesh.js +4 -4
  9. package/dist/core/instanced/instanced-material.js +1 -1
  10. package/dist/core/lines/BatchedLines.d.ts +4 -4
  11. package/dist/core/lines/BatchedLines.js +4 -4
  12. package/dist/core/shader-source.d.ts +20 -0
  13. package/dist/core/shader-source.js +26 -0
  14. package/dist/core/text/BatchedText.d.ts +14 -14
  15. package/dist/core/text/BatchedText.js +9 -9
  16. package/dist/core/text/MemberTexture.d.ts +2 -2
  17. package/dist/core/text/Text.d.ts +5 -5
  18. package/dist/core/text/Text.js +4 -4
  19. package/dist/core/text/glyph-packing.d.ts +2 -2
  20. package/dist/debug/components/Gui.d.ts +2 -0
  21. package/dist/debug/components/Gui.js +11 -1
  22. package/dist/events/layout-targets.d.ts +7 -3
  23. package/dist/events/layout-targets.js +13 -1
  24. package/dist/external/ExternalCanvas.js +5 -6
  25. package/dist/external/external-camera.js +26 -11
  26. package/dist/index.d.ts +1 -0
  27. package/dist/objects/Background.js +1 -1
  28. package/dist/objects/containers/SlotContainer.d.ts +27 -2
  29. package/dist/objects/containers/SlotContainer.js +36 -1
  30. package/dist/objects/containers/hooks.js +1 -1
  31. package/dist/objects/containers/mesh-writes.d.ts +1 -1
  32. package/dist/objects/containers/props.d.ts +10 -0
  33. package/dist/objects/images/Images.js +10 -3
  34. package/dist/objects/images/image-container.d.ts +5 -5
  35. package/dist/objects/images/image-container.js +2 -2
  36. package/dist/objects/layer/Layer.js +10 -10
  37. package/dist/objects/layer/mount-scheduler.d.ts +83 -54
  38. package/dist/objects/layer/mount-scheduler.js +204 -60
  39. package/dist/objects/lighting/Lighting.d.ts +92 -0
  40. package/dist/objects/lighting/Lighting.js +385 -0
  41. package/dist/objects/lighting/OcclusionPasses.d.ts +23 -0
  42. package/dist/objects/lighting/OcclusionPasses.js +271 -0
  43. package/dist/objects/lighting/index.d.ts +11 -0
  44. package/dist/objects/lighting/index.js +10 -0
  45. package/dist/objects/lighting/lighting-gui.d.ts +24 -0
  46. package/dist/objects/lighting/lighting-gui.js +114 -0
  47. package/dist/objects/lighting/occluder.d.ts +34 -0
  48. package/dist/objects/lighting/occluder.js +62 -0
  49. package/dist/objects/lighting/occlusion-targets.d.ts +143 -0
  50. package/dist/objects/lighting/occlusion-targets.js +276 -0
  51. package/dist/objects/lines/Lines.js +3 -1
  52. package/dist/objects/lines/line-container.d.ts +4 -4
  53. package/dist/objects/materials/ColorMaterial.d.ts +58 -51
  54. package/dist/objects/materials/ColorMaterial.js +74 -52
  55. package/dist/objects/materials/LineMaterial.d.ts +1 -1
  56. package/dist/objects/materials/LineMaterial.js +1 -1
  57. package/dist/objects/materials/ModelMaterial.d.ts +30 -18
  58. package/dist/objects/materials/ModelMaterial.js +68 -41
  59. package/dist/objects/materials/TextureMaterial.js +1 -1
  60. package/dist/objects/materials/effects/ao/ambient-occlusion-pass.d.ts +77 -0
  61. package/dist/objects/materials/effects/ao/ambient-occlusion-pass.js +296 -0
  62. package/dist/objects/materials/effects/ao/ambient-occlusion-patch.d.ts +29 -0
  63. package/dist/objects/materials/effects/ao/ambient-occlusion-patch.js +115 -0
  64. package/dist/objects/materials/effects/ao/blur-pass.d.ts +61 -0
  65. package/dist/objects/materials/effects/ao/blur-pass.js +101 -0
  66. package/dist/objects/materials/effects/ao/index.d.ts +129 -0
  67. package/dist/objects/materials/effects/ao/index.js +72 -0
  68. package/dist/objects/materials/{dim.d.ts → effects/color-dim.d.ts} +5 -5
  69. package/dist/objects/materials/{dim.js → effects/color-dim.js} +7 -25
  70. package/dist/objects/materials/effects/contact-shadow.d.ts +100 -0
  71. package/dist/objects/materials/effects/contact-shadow.js +167 -0
  72. package/dist/objects/materials/effects/effects-context.d.ts +52 -0
  73. package/dist/objects/materials/effects/effects-context.js +24 -0
  74. package/dist/objects/models/Models.js +20 -4
  75. package/dist/objects/models/model-container.d.ts +4 -20
  76. package/dist/objects/models/model-container.js +2 -22
  77. package/dist/objects/shapes/Shapes.js +20 -2
  78. package/dist/objects/shapes/shape-container.d.ts +3 -20
  79. package/dist/objects/shapes/shape-container.js +0 -21
  80. package/dist/objects/text/Texts.js +3 -1
  81. package/dist/objects/text/text-container.d.ts +6 -6
  82. package/dist/objects/text/text-container.js +4 -4
  83. package/dist/objects/text/text-layout.d.ts +2 -2
  84. package/dist/space/Scene.d.ts +3 -0
  85. package/dist/space/Scene.js +17 -5
  86. package/package.json +2 -2
  87. package/dist/objects/Lighting.d.ts +0 -20
  88. package/dist/objects/Lighting.js +0 -85
@@ -1,42 +1,60 @@
1
1
  import { type LayerDef, type RenderableDef, type RenderableDefCollection } from '../../types/defs';
2
2
  /**
3
- * Admits shape layers into the scene one per frame, in registration order.
3
+ * Lets a shape layer mount its children a slice at a time, in registration order.
4
4
  *
5
- * Mounting a large layer's instances is one unbreakable commit — `useDeferredValue` can slice a
6
- * render phase, but nothing can slice a commit — so the unit of progressive mounting is the leaf
7
- * layer: a scene's first (small) layers paint without waiting for every later layer's commit.
8
- * Priority is registration order, which is the **flattened leaf order** of the tree: leaves
9
- * register from a passive mount effect, and React runs those children-first, in sibling order.
10
- * Pacing is one admission per animation frame, granted after the previously admitted leaf reports
11
- * its content committed — so each layer's frame actually presents before the next layer's commit
12
- * begins.
5
+ * React cannot split a commit. `useDeferredValue` splits a render phase, but the commit that
6
+ * follows runs to the end. So the unit of progressive mounting is whatever one commit mounts, and
7
+ * that unit is a slice of one leaf layer's children. Without slices, a layer holding thousands of
8
+ * defs mounts them in one commit, and no pacing of whole layers makes that frame shorter.
13
9
  *
14
- * An **empty** leaf is exempt: it has no instances to commit, so it is admitted on registration and
15
- * never paced. Without that, a layer filled after mount — wayfinding's route, markers, traffic —
16
- * would have to wait out the staging of every leaf ahead of it (a real plan runs to ~90 leaves,
17
- * ~20s) before it were allowed to draw at all.
10
+ * Priority is registration order, which is the flattened leaf order of the tree. Leaves register
11
+ * from a passive mount effect, and React runs those children first, in sibling order.
12
+ *
13
+ * A frame keeps taking slices until its budget runs out, and hands the rest to the next frame. One
14
+ * slice per frame was measured as 64% of the staging spent waiting rather than mounting.
15
+ *
16
+ * **What the budget covers.** Each slice is timed from the grant to the report, so the measurement
17
+ * holds React's work: the render phase, the commit, and the effects that claim slots and write
18
+ * their state. It does not hold what the frame does next. There three culls and sorts, uploads
19
+ * buffers and textures, compiles a new material's program and draws, and then the browser paints.
20
+ * Those cost more than the mount on a busy startup frame, and the budget cannot see any of them.
21
+ * Treat the budget as a cap on what mounting adds to a frame, not as a frame time.
22
+ *
23
+ * The slice size comes from measurement rather than from a fixed count, because a child of an image
24
+ * layer costs about ten times what a child of a shape layer costs. Every slice is timed, and the
25
+ * next one is sized from what the last one cost per child. One floor overrides the budget: a layer
26
+ * is never cut into more than {@link MAX_SLICES_PER_LEAF} slices, because a layer re-renders its
27
+ * whole mounted prefix on every slice.
28
+ *
29
+ * An empty leaf is exempt. It has no instances to commit, so it is admitted when it registers and
30
+ * is never paced. Without that, a layer filled after mount, such as wayfinding's route, the markers
31
+ * or the traffic, waits out the staging of every leaf ahead of it before it can draw. A leaf that
32
+ * finishes staging is exempt from then on, for the same reason: whatever it gains later mounts at
33
+ * once, as the one small commit it is.
18
34
  */
19
35
  export interface MountScheduler {
20
36
  /**
21
- * Queues a leaf for admission. Call from a mount effect so call order is the flattened leaf
22
- * order. Admission is sticky: a leaf that re-registers (StrictMode effect cycling, a remount)
23
- * stays admitted.
37
+ * Queues a leaf for admission. Call it from a mount effect, so the call order is the flattened
38
+ * leaf order. Admission is sticky: a leaf that registers again, after a StrictMode effect cycle
39
+ * or a remount, keeps every child it was already granted.
24
40
  * @param leaf - The shape layer to queue.
25
41
  * @returns An unregister function.
26
42
  */
27
43
  register(leaf: LayerDef): () => void;
28
44
  /**
29
- * The queued leaves, in registration order — the flattened leaf order of the mounted tree. The
30
- * queue doubles as the tree's leaf census, which is what the layer-visibility GUI lists.
45
+ * The queued leaves, in registration order, which is the flattened leaf order of the mounted
46
+ * tree. The queue is also the tree's leaf census, and that is what the layer-visibility GUI
47
+ * lists.
31
48
  * @returns A snapshot of the queue.
32
49
  */
33
50
  leaves(): readonly LayerDef[];
34
51
  /**
35
- * Whether a leaf may mount its content — the `useSyncExternalStore` snapshot.
52
+ * How many of a leaf's children can mount their content now, counted from the start of its list.
53
+ * This is the `useSyncExternalStore` snapshot.
36
54
  * @param leaf - The shape layer to check.
37
- * @returns `true` once admitted.
55
+ * @returns The admitted count, which is every child once the leaf finishes staging.
38
56
  */
39
- isAdmitted(leaf: LayerDef): boolean;
57
+ admittedCount(leaf: LayerDef): number;
40
58
  /**
41
59
  * Subscribes to scheduler changes: admissions, registrations, and unregistrations.
42
60
  * @param listener - Called after every change.
@@ -44,59 +62,70 @@ export interface MountScheduler {
44
62
  */
45
63
  subscribe(listener: () => void): () => void;
46
64
  /**
47
- * Marks the admitted leaf's content as committed, releasing the next admission on the following
48
- * animation frame.
65
+ * Reports that the admitted slice committed its content. The scheduler records what the slice
66
+ * cost and releases the next one. It releases it inside this frame while the budget holds, and on
67
+ * the next frame once it does not.
49
68
  * @param leaf - The shape layer whose content committed.
50
69
  */
51
70
  reportMounted(leaf: LayerDef): void;
52
71
  }
53
- /** What {@link useStagedChildren} hands a layer: the children to render, and their admission. */
72
+ /** What {@link useStagedChildren} hands a layer: the children to render, and how many can mount. */
54
73
  export interface StagedChildren<T extends RenderableDef> {
55
- /** The layer's children — the deferred, version-keyed snapshot described on the hook. */
74
+ /** The layer's children, as the deferred and version-keyed snapshot the hook describes. */
56
75
  children: Readonly<RenderableDefCollection<T>>;
57
- /** Whether a leaf layer may mount its content; always `true` for a layer of layers. */
58
- admitted: boolean;
76
+ /**
77
+ * How many of `children` can mount their instances, counted from the start of the list. A layer
78
+ * of layers is not staged, so it always gets all of them.
79
+ */
80
+ mounted: number;
59
81
  }
60
82
  /**
61
83
  * Builds the scheduler a `Layer` tree's root provides.
84
+ * @param budgetMs - How much of a frame mounting can take, in milliseconds, across as many slices
85
+ * as fit. The frame also has to draw, so this is a fraction of a frame rather than the whole of
86
+ * one. A budget smaller than one slice paces one slice per frame, which is what a spec wants when
87
+ * its commits cost nothing measurable.
62
88
  * @returns The scheduler.
63
89
  */
64
- export declare function createMountScheduler(): MountScheduler;
90
+ export declare function createMountScheduler(budgetMs?: number): MountScheduler;
65
91
  /**
66
- * A layer's children as they should render now, together with the staged-mount admission for their
67
- * content. Three behaviors fold together here, because their consumer needs all of them and the
68
- * intermediate values are footguns — rendering the un-deferred snapshot would defeat the deferral:
92
+ * A layer's children as they render now, together with how many of them can mount.
93
+ *
94
+ * Three behaviors fold together here, because their consumer needs all three, and because the
95
+ * values in between are traps. Rendering the snapshot before the deferral defeats the deferral.
69
96
  *
70
- * - **Subscription.** The store's version-keyed snapshot of `layer.children`: a new identity
71
- * exactly when the layer is updated, the cached one otherwise. The layer's own array is mutated
72
- * in place, so passing it down would leave a memoized container stale; the snapshot is what makes
73
- * `layer.hidden = true; update(layer)` re-render the layer while child identities survive, so
74
- * keyed reconciliation mounts and unmounts only the membership delta.
75
- * - **Deferral.** Children flow through `useDeferredValue`, so a membership change's urgent render
76
- * is the layer component alone; the container's instance-list sweep re-renders at transition
77
- * priority, interruptible, keeping frames flowing. `update(def)` writes on leaf defs are
78
- * unaffected — they never pass through `children`.
79
- * - **Admission.** Whether a leaf may mount its content, per {@link useStagedMount}. A leaf that is
80
- * not yet admitted renders `null` rather than an empty container, so each batch is still created
81
- * at its full size and never reallocates on mount. Defs stay the source of truth: `update()`
82
- * against a not-yet-admitted layer is harmless, because admission mounts whatever the defs hold
83
- * at that moment.
97
+ * - **Subscription.** The store's version-keyed snapshot of `layer.children`. It is a new identity
98
+ * exactly when the layer is updated, and the cached one otherwise. The layer's own array is
99
+ * mutated in place, so passing it down leaves a memoized container stale. The snapshot is what
100
+ * makes `layer.hidden = true; update(layer)` re-render the layer while the child identities
101
+ * survive, so keyed reconciliation mounts and unmounts only the membership delta.
102
+ * - **Deferral.** Children flow through `useDeferredValue`, so the urgent render of a membership
103
+ * change is the layer component alone. The container's instance-list sweep re-renders at
104
+ * transition priority, where React can interrupt it, and the frames keep flowing. `update(def)`
105
+ * writes on leaf defs are unaffected, because they never pass through `children`.
106
+ * - **Admission.** How many of a leaf's children can mount their content, per {@link useStagedMount}.
107
+ * A leaf granted nothing yet renders `null` rather than an empty container, so each batch is
108
+ * still created at its full size and never reallocates as the granted prefix grows. Defs stay the
109
+ * source of truth: `update()` against a def that has not mounted yet is harmless, because its
110
+ * instance writes whatever the def holds when it does mount.
84
111
  * @param layer - The layer to observe.
85
- * @returns The current children snapshot and the admission flag.
112
+ * @returns The current children snapshot and how many of them can mount.
86
113
  */
87
114
  export declare function useStagedChildren<T extends RenderableDef>(layer: LayerDef<T>): StagedChildren<T>;
88
115
  /**
89
- * Whether a layer may mount its content, per the tree's scheduler.
116
+ * How many of a layer's children can mount, per the tree's scheduler.
90
117
  *
91
- * Consumes the scheduler {@link MountSchedulerContext} provides. Without one — a plain `<Layer>`
92
- * mount, or staging disabled — everything is admitted immediately and no queueing happens. Under a
93
- * scheduler, a leaf is admitted one per frame in flattened leaf order, and reports back from the
94
- * admission commit so the next leaf is released only after this one's content committed.
118
+ * Consumes the scheduler {@link MountSchedulerContext} provides. Without one, which is a plain
119
+ * `<Layer>` mount or staging turned off, everything is admitted at once and nothing queues. Under a
120
+ * scheduler, a leaf is granted a slice of its children at a time, in flattened leaf order. It
121
+ * reports back from each admission commit, so the next slice is released only after this one's
122
+ * content committed.
95
123
  * @param layer - The layer def.
96
- * @param leaf - Whether the layer is a shape layer; only leaves are staged.
97
- * @returns Whether the layer's content may mount.
124
+ * @param leaf - Whether the layer is a shape layer. Only leaves are staged.
125
+ * @param total - How many children the layer holds, which is what an unstaged layer mounts.
126
+ * @returns How many of the layer's children can mount their content.
98
127
  */
99
- export declare function useStagedMount(layer: LayerDef, leaf: boolean): boolean;
128
+ export declare function useStagedMount(layer: LayerDef, leaf: boolean, total: number): number;
100
129
  /** The tree's scheduler, provided by `Scene` and inherited by every `Layer` beneath it. */
101
130
  export declare const MountSchedulerContext: import("react").Context<MountScheduler | null>;
102
131
  //# sourceMappingURL=mount-scheduler.d.ts.map
@@ -1,19 +1,44 @@
1
1
  import { createContext, use, useDeferredValue, useEffect, useSyncExternalStore } from 'react';
2
2
  import { isLayerDefs } from '../../types/guards';
3
3
  import { childrenOf, subscribeDef } from '../../update';
4
+ import { createLogger } from '../../utils/logging';
5
+ const logger = createLogger('mount');
4
6
  /**
5
7
  * Builds the scheduler a `Layer` tree's root provides.
8
+ * @param budgetMs - How much of a frame mounting can take, in milliseconds, across as many slices
9
+ * as fit. The frame also has to draw, so this is a fraction of a frame rather than the whole of
10
+ * one. A budget smaller than one slice paces one slice per frame, which is what a spec wants when
11
+ * its commits cost nothing measurable.
6
12
  * @returns The scheduler.
7
13
  */
8
- export function createMountScheduler() {
14
+ export function createMountScheduler(budgetMs = FRAME_BUDGET_MS) {
9
15
  const queue = [];
10
- // Weak and sticky: admission survives unregister (a StrictMode effect cycle must not unmount
11
- // content already on screen), and an entry is collected with its def.
12
- const admitted = new WeakSet();
16
+ // How many children each leaf has been granted. Weak, so an entry goes when its def goes. Sticky,
17
+ // so an unregister keeps the count: a StrictMode effect cycle must not unmount content that is
18
+ // already on screen.
19
+ const granted = new WeakMap();
20
+ // Leaves that are not paced at all. A leaf lands here when it registers empty, and when it
21
+ // finishes staging. Both mount whatever they hold the moment they hold it.
22
+ const unpaced = new WeakSet();
23
+ // What one child of this leaf cost to mount, in milliseconds, as last measured. Kept per leaf
24
+ // because the spread is wide: a plain shape costs a fraction of what an image costs.
25
+ const rateByLeaf = new WeakMap();
13
26
  const listeners = new Set();
14
27
  let awaiting = null;
28
+ let awaitingSize = 0;
29
+ let awaitingSince = 0;
15
30
  let pacing = false;
16
- // Frame pacing in the browser; a macrotask in headless spec environments without rAF.
31
+ // What this frame already spent on mounting, and how many slices it took. A frame keeps taking
32
+ // slices until one of the two runs out. That is what stops the pacing from costing a whole frame
33
+ // per slice, which on a large plan leaves most of the staging waiting instead of mounting.
34
+ let frameSpent = 0;
35
+ let frameSlices = 0;
36
+ // The leaves this frame granted to, for the one debug line each of them gets when the frame ends.
37
+ const framed = [];
38
+ // Seeds the first slice of a leaf that nothing has measured yet. Every measurement refreshes it,
39
+ // so the seed follows the scene being mounted rather than a number chosen here.
40
+ let rate = INITIAL_MS_PER_CHILD;
41
+ // Frame pacing in the browser. A macrotask in headless spec environments, which have no rAF.
17
42
  const nextFrame = typeof requestAnimationFrame === 'function'
18
43
  ? (callback) => void requestAnimationFrame(callback)
19
44
  : (callback) => void setTimeout(callback, 0);
@@ -21,25 +46,97 @@ export function createMountScheduler() {
21
46
  for (const listener of [...listeners])
22
47
  listener();
23
48
  };
49
+ /**
50
+ * How many more of a leaf's children the next commit can take.
51
+ * @param leaf - The leaf being staged.
52
+ * @param from - How many of its children are already granted.
53
+ * @param available - What is left of this frame's mounting budget, in milliseconds.
54
+ * @returns The slice size, at least one child and never past the end.
55
+ */
56
+ const sliceFor = (leaf, from, available) => {
57
+ const measured = rateByLeaf.get(leaf);
58
+ const fit = Math.max(1, Math.round(available / Math.max(measured ?? rate, MIN_MS_PER_CHILD)));
59
+ // A leaf that nothing has measured gets a capped first slice. That slice pays the fixed costs
60
+ // of the layer, which are the material's compile, the mesh's allocation and the first atlas
61
+ // upload. A seed taken from a cheaper layer sizes it far too generously.
62
+ const size = measured === undefined ? Math.min(fit, FIRST_SLICE) : fit;
63
+ // The floor is what keeps slicing from turning quadratic. A layer renders its whole mounted
64
+ // prefix on every slice, so the elements React builds over a staging run come to slices times
65
+ // prefix. That is cheap over a handful of slices and ruinous over hundreds. The floor caps a
66
+ // layer at MAX_SLICES_PER_LEAF slices and overrides the budget to do it, so a big layer takes
67
+ // one frame longer than the budget instead of paying that cost hundreds of times.
68
+ const floor = Math.ceil(leaf.children.length / MAX_SLICES_PER_LEAF);
69
+ return Math.min(leaf.children.length - from, Math.max(size, floor));
70
+ };
71
+ /**
72
+ * How many of a leaf's children can mount now.
73
+ * @param leaf - The leaf to count.
74
+ * @returns The admitted count.
75
+ */
76
+ const countOf = (leaf) => unpaced.has(leaf)
77
+ ? leaf.children.length
78
+ : Math.min(granted.get(leaf) ?? 0, leaf.children.length);
79
+ /** Reports what the frame admitted, one line per layer it touched, and clears the tally. */
80
+ const logFrame = () => {
81
+ if (framed.length === 0)
82
+ return;
83
+ for (const leaf of framed) {
84
+ logger.debug(`${leaf.name ?? '?'}, admitted ${countOf(leaf)}/${leaf.children.length}`);
85
+ }
86
+ framed.length = 0;
87
+ };
88
+ /** Hands the rest of the queue to the next frame, and starts that frame's budget over. */
89
+ const yieldFrame = () => {
90
+ if (pacing)
91
+ return;
92
+ pacing = true;
93
+ logFrame();
94
+ nextFrame(() => {
95
+ pacing = false;
96
+ frameSpent = 0;
97
+ frameSlices = 0;
98
+ pump();
99
+ });
100
+ };
24
101
  const pump = () => {
25
102
  if (awaiting || pacing)
26
103
  return;
27
- const next = queue.find((leaf) => !admitted.has(leaf));
28
- if (!next)
104
+ const next = queue.find((leaf) => !unpaced.has(leaf) && (granted.get(leaf) ?? 0) < leaf.children.length);
105
+ if (!next) {
106
+ logFrame();
29
107
  return;
30
- admitted.add(next);
108
+ }
109
+ const available = budgetMs - frameSpent;
110
+ // Out of budget, or enough slices for one frame, so the rest waits for the next frame. A frame
111
+ // always takes its first slice, however small the budget is, so the scheduler can never stall.
112
+ // The re-layout drain follows the same rule. The slice cap is a guard rather than a policy:
113
+ // every grant is a React update raised from an effect, and React stops a component that raises
114
+ // them without end.
115
+ const spent = available < MIN_SLICE_BUDGET_MS || frameSlices >= MAX_SLICES_PER_FRAME;
116
+ if (spent && frameSlices > 0) {
117
+ yieldFrame();
118
+ return;
119
+ }
120
+ const from = granted.get(next) ?? 0;
121
+ const size = sliceFor(next, from, available);
122
+ granted.set(next, from + size);
123
+ if (from + size >= next.children.length)
124
+ unpaced.add(next);
31
125
  awaiting = next;
126
+ awaitingSize = size;
127
+ awaitingSince = performance.now();
128
+ if (!framed.includes(next))
129
+ framed.push(next);
32
130
  notifyAll();
33
131
  };
34
132
  return {
35
133
  register: (leaf) => {
36
- // An empty leaf has no instances to commit, so it costs nothing to admit and nothing to
37
- // pace: it takes no queue slot and never becomes `awaiting`. This is what keeps a layer
38
- // filled *after* mount — wayfinding's route, markers, traffic — from waiting out the whole
39
- // plan's staging before it may draw. Admission is sticky, so the later fill mounts at once,
40
- // as the one small commit it is.
41
- if (leaf.children.length === 0 && !admitted.has(leaf)) {
42
- admitted.add(leaf);
134
+ // An empty leaf has no instances to commit. It costs nothing to admit and nothing to pace, so
135
+ // it never becomes `awaiting`. This is what lets a layer filled after mount, such as
136
+ // wayfinding's route, the markers or the traffic, draw without waiting out the whole staging.
137
+ // It stays exempt, so the later fill mounts at once, as the one small commit it is.
138
+ if (leaf.children.length === 0 && !unpaced.has(leaf)) {
139
+ unpaced.add(leaf);
43
140
  notifyAll();
44
141
  }
45
142
  if (!queue.includes(leaf)) {
@@ -53,7 +150,7 @@ export function createMountScheduler() {
53
150
  queue.splice(index, 1);
54
151
  notifyAll();
55
152
  }
56
- // An admitted leaf that unmounts before reporting must not stall the queue forever.
153
+ // An admitted leaf that unmounts before it reports must not stall the queue for good.
57
154
  if (awaiting === leaf) {
58
155
  awaiting = null;
59
156
  pump();
@@ -61,7 +158,9 @@ export function createMountScheduler() {
61
158
  };
62
159
  },
63
160
  leaves: () => [...queue],
64
- isAdmitted: (leaf) => admitted.has(leaf),
161
+ admittedCount: (leaf) => unpaced.has(leaf)
162
+ ? leaf.children.length
163
+ : Math.min(granted.get(leaf) ?? 0, leaf.children.length),
65
164
  subscribe: (listener) => {
66
165
  listeners.add(listener);
67
166
  return () => {
@@ -71,75 +170,120 @@ export function createMountScheduler() {
71
170
  reportMounted: (leaf) => {
72
171
  if (awaiting !== leaf)
73
172
  return;
173
+ // Timed from the grant. The effect this runs from is inside the commit the grant caused, and
174
+ // it runs after the children's own effects, so the span covers that commit's React work. It
175
+ // does not cover the draw that follows, which the interface doc explains.
176
+ const cost = performance.now() - awaitingSince;
177
+ if (awaitingSize > 0) {
178
+ rate = Math.max(cost / awaitingSize, MIN_MS_PER_CHILD);
179
+ rateByLeaf.set(leaf, rate);
180
+ }
74
181
  awaiting = null;
75
- // The pacing flag keeps a registration arriving before the next frame from admitting
76
- // synchronously and skipping the one-per-frame cadence.
77
- pacing = true;
78
- nextFrame(() => {
79
- pacing = false;
80
- pump();
81
- });
182
+ frameSpent += cost;
183
+ frameSlices++;
184
+ // Straight on to the next slice while this frame still has budget. `pump` hands the queue to
185
+ // the next frame itself once the budget or the slice count runs out.
186
+ pump();
82
187
  },
83
188
  };
84
189
  }
85
190
  /**
86
- * A layer's children as they should render now, together with the staged-mount admission for their
87
- * content. Three behaviors fold together here, because their consumer needs all of them and the
88
- * intermediate values are footguns — rendering the un-deferred snapshot would defeat the deferral:
191
+ * A layer's children as they render now, together with how many of them can mount.
89
192
  *
90
- * - **Subscription.** The store's version-keyed snapshot of `layer.children`: a new identity
91
- * exactly when the layer is updated, the cached one otherwise. The layer's own array is mutated
92
- * in place, so passing it down would leave a memoized container stale; the snapshot is what makes
93
- * `layer.hidden = true; update(layer)` re-render the layer while child identities survive, so
94
- * keyed reconciliation mounts and unmounts only the membership delta.
95
- * - **Deferral.** Children flow through `useDeferredValue`, so a membership change's urgent render
96
- * is the layer component alone; the container's instance-list sweep re-renders at transition
97
- * priority, interruptible, keeping frames flowing. `update(def)` writes on leaf defs are
98
- * unaffected — they never pass through `children`.
99
- * - **Admission.** Whether a leaf may mount its content, per {@link useStagedMount}. A leaf that is
100
- * not yet admitted renders `null` rather than an empty container, so each batch is still created
101
- * at its full size and never reallocates on mount. Defs stay the source of truth: `update()`
102
- * against a not-yet-admitted layer is harmless, because admission mounts whatever the defs hold
103
- * at that moment.
193
+ * Three behaviors fold together here, because their consumer needs all three, and because the
194
+ * values in between are traps. Rendering the snapshot before the deferral defeats the deferral.
195
+ *
196
+ * - **Subscription.** The store's version-keyed snapshot of `layer.children`. It is a new identity
197
+ * exactly when the layer is updated, and the cached one otherwise. The layer's own array is
198
+ * mutated in place, so passing it down leaves a memoized container stale. The snapshot is what
199
+ * makes `layer.hidden = true; update(layer)` re-render the layer while the child identities
200
+ * survive, so keyed reconciliation mounts and unmounts only the membership delta.
201
+ * - **Deferral.** Children flow through `useDeferredValue`, so the urgent render of a membership
202
+ * change is the layer component alone. The container's instance-list sweep re-renders at
203
+ * transition priority, where React can interrupt it, and the frames keep flowing. `update(def)`
204
+ * writes on leaf defs are unaffected, because they never pass through `children`.
205
+ * - **Admission.** How many of a leaf's children can mount their content, per {@link useStagedMount}.
206
+ * A leaf granted nothing yet renders `null` rather than an empty container, so each batch is
207
+ * still created at its full size and never reallocates as the granted prefix grows. Defs stay the
208
+ * source of truth: `update()` against a def that has not mounted yet is harmless, because its
209
+ * instance writes whatever the def holds when it does mount.
104
210
  * @param layer - The layer to observe.
105
- * @returns The current children snapshot and the admission flag.
211
+ * @returns The current children snapshot and how many of them can mount.
106
212
  */
107
213
  export function useStagedChildren(layer) {
108
214
  const snapshot = useSyncExternalStore((listener) => subscribeDef(layer, listener), () => childrenOf(layer));
109
215
  const children = useDeferredValue(snapshot);
110
- const admitted = useStagedMount(layer, !isLayerDefs(children));
111
- return { children, admitted };
216
+ const mounted = useStagedMount(layer, !isLayerDefs(children), children.length);
217
+ return { children, mounted };
112
218
  }
113
219
  /**
114
- * Whether a layer may mount its content, per the tree's scheduler.
220
+ * How many of a layer's children can mount, per the tree's scheduler.
115
221
  *
116
- * Consumes the scheduler {@link MountSchedulerContext} provides. Without one — a plain `<Layer>`
117
- * mount, or staging disabled — everything is admitted immediately and no queueing happens. Under a
118
- * scheduler, a leaf is admitted one per frame in flattened leaf order, and reports back from the
119
- * admission commit so the next leaf is released only after this one's content committed.
222
+ * Consumes the scheduler {@link MountSchedulerContext} provides. Without one, which is a plain
223
+ * `<Layer>` mount or staging turned off, everything is admitted at once and nothing queues. Under a
224
+ * scheduler, a leaf is granted a slice of its children at a time, in flattened leaf order. It
225
+ * reports back from each admission commit, so the next slice is released only after this one's
226
+ * content committed.
120
227
  * @param layer - The layer def.
121
- * @param leaf - Whether the layer is a shape layer; only leaves are staged.
122
- * @returns Whether the layer's content may mount.
228
+ * @param leaf - Whether the layer is a shape layer. Only leaves are staged.
229
+ * @param total - How many children the layer holds, which is what an unstaged layer mounts.
230
+ * @returns How many of the layer's children can mount their content.
123
231
  */
124
- export function useStagedMount(layer, leaf) {
232
+ export function useStagedMount(layer, leaf, total) {
125
233
  const scheduler = use(MountSchedulerContext);
126
- const admitted = useSyncExternalStore((onStoreChange) => (scheduler ? scheduler.subscribe(onStoreChange) : noopUnsubscribe), () => !leaf || !scheduler || scheduler.isAdmitted(layer));
127
- // Registration from a passive effect, deliberately: effect order across the tree is the
128
- // flattened leaf order, which is the admission priority.
234
+ const mounted = useSyncExternalStore((onStoreChange) => (scheduler ? scheduler.subscribe(onStoreChange) : noopUnsubscribe), () => (!leaf || !scheduler ? total : scheduler.admittedCount(layer)));
235
+ // Registration from a passive effect, deliberately. Effect order across the tree is the flattened
236
+ // leaf order, and that order is the admission priority.
129
237
  useEffect(() => {
130
238
  if (!scheduler || !leaf)
131
239
  return undefined;
132
240
  return scheduler.register(layer);
133
241
  }, [scheduler, layer, leaf]);
134
- // Runs in the admission commit, after the children's effects — the content's slots are claimed
135
- // by the time the next leaf is released.
242
+ // Runs in the admission commit, after the children's effects, so the slice has claimed its slots
243
+ // by the time the next one is released. Keyed on the count, so every slice reports, not only the
244
+ // first.
136
245
  useEffect(() => {
137
- if (scheduler && leaf && admitted)
246
+ if (scheduler && leaf && mounted > 0)
138
247
  scheduler.reportMounted(layer);
139
- }, [scheduler, layer, leaf, admitted]);
140
- return admitted;
248
+ }, [scheduler, layer, leaf, mounted]);
249
+ return mounted;
141
250
  }
142
251
  /** The tree's scheduler, provided by `Scene` and inherited by every `Layer` beneath it. */
143
252
  export const MountSchedulerContext = createContext(null);
253
+ /**
254
+ * How much of a frame mounting can take, in milliseconds, across as many slices as fit. Two thirds
255
+ * of a 60Hz frame, which leaves the draw and the paint their share. The budget is what a frame
256
+ * spends in total, not what one slice spends: a budget of one slice per frame leaves a large scene
257
+ * waiting on frames for most of its staging.
258
+ */
259
+ const FRAME_BUDGET_MS = 11;
260
+ /**
261
+ * The least budget worth starting a slice on, in milliseconds. Below it the frame counts as spent.
262
+ * The alternative is a one-child slice, which still costs a whole React commit.
263
+ */
264
+ const MIN_SLICE_BUDGET_MS = 2;
265
+ /**
266
+ * The most slices one frame takes, however cheap they are. Every grant is a React update raised
267
+ * from an effect, and React stops a component that raises them without end.
268
+ */
269
+ const MAX_SLICES_PER_FRAME = 12;
270
+ /**
271
+ * What a child is assumed to cost before anything is measured, in milliseconds. Deliberately
272
+ * pessimistic, because the first slice of the first leaf is the one grant no measurement informs.
273
+ */
274
+ const INITIAL_MS_PER_CHILD = 0.25;
275
+ /**
276
+ * The cheapest a child is ever assumed to be, in milliseconds. Without a floor, a layer whose slice
277
+ * measured as instant sizes the next one at every child it holds.
278
+ */
279
+ const MIN_MS_PER_CHILD = 0.02;
280
+ /** The most children a leaf is granted before one of its own slices has been measured. */
281
+ const FIRST_SLICE = 64;
282
+ /**
283
+ * The most slices one leaf is ever cut into. A layer re-renders its whole mounted prefix per slice,
284
+ * so the number of slices decides how much of that cost the staging pays. Eight keeps it to a few
285
+ * thousand elements even for a layer of several thousand defs.
286
+ */
287
+ const MAX_SLICES_PER_LEAF = 8;
144
288
  // What subscribing without a scheduler returns: nothing to unsubscribe from.
145
289
  const noopUnsubscribe = () => undefined;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Props for {@link Lighting}: every value the debug GUI can move, apart from the two that aim the key light.
3
+ *
4
+ * Each one is optional, and each one overrides the design's own default for as long as it is given.
5
+ * The pitch and `trackPitch` are deliberately absent. Where the key light points is the design's
6
+ * decision rather than a caller's, and the GUI keeps both of them for settling it.
7
+ *
8
+ * **A prop beats the GUI only when the prop changes.** {@link Lighting} writes these into the rig
9
+ * and into the scene's effect state from an effect keyed on the values themselves, so a control the
10
+ * GUI moved stays moved until a caller passes something different.
11
+ */
12
+ export interface LightingProps extends Partial<Omit<LightSettings, 'trackPitch' | 'pitchDegrees'>> {
13
+ /** The shade both darkening terms blend toward, and each term's own numbers. */
14
+ shadow?: ShadowProps;
15
+ }
16
+ /**
17
+ * The darkening values: the shade both terms share, the contact shadow's two numbers, and ambient
18
+ * occlusion's switch and two numbers.
19
+ *
20
+ * One object rather than six loose props, because they are one visual decision. The names are the
21
+ * GUI's own labels, so a caller and a slider talk about the same thing — and so that the two
22
+ * strengths cannot be mistaken for each other, which is the reason the GUI renamed them.
23
+ *
24
+ * Omit a value to keep the design's own default. `CONTACT_SHADOW_DEFAULTS` and `OCCLUSION_DEFAULTS`
25
+ * are where those live, each beside the term that owns it.
26
+ */
27
+ export interface ShadowProps {
28
+ /** The shade both terms blend toward, as a CSS hex string. */
29
+ color?: string;
30
+ /** The contact shadow's reach above a volume's base, in plan units. */
31
+ contactDistance?: number;
32
+ /** How far the contact shadow blends a volume's base toward the shade, from `0` to `1`. */
33
+ contactStrength?: number;
34
+ /**
35
+ * Whether ambient occlusion runs at all.
36
+ *
37
+ * A switch rather than a capability: the term also turns itself off for good on a context that
38
+ * cannot give it a depth texture, and there is nothing here to say about that.
39
+ */
40
+ ambientOcclusion?: boolean;
41
+ /** How far ambient occlusion looks for an occluder, in plan units. */
42
+ aoRadius?: number;
43
+ /** How far ambient occlusion blends an occluded fragment toward the shade, from `0` to `1`. */
44
+ aoStrength?: number;
45
+ }
46
+ /**
47
+ * The light rig, as the debug GUI edits it.
48
+ *
49
+ * One object per mounted `Lighting`, mutated in place: the frame reads it, so a slider drag costs a
50
+ * few numbers and no render. A remount gives a fresh rig rather than reusing module state.
51
+ */
52
+ export interface LightSettings {
53
+ /** Whether the light copies the camera's backward axis in full, the pre-overhaul behavior. */
54
+ trackPitch: boolean;
55
+ /** The light's angle from the plan's up axis, in degrees. Unread while `trackPitch` is on. */
56
+ pitchDegrees: number;
57
+ /**
58
+ * How directional the rig is, from `0` (pure hemisphere, flat) to `1` (the most directional light
59
+ * this pitch and these colors take before a wall clips). Normalized rather than an intensity ratio,
60
+ * whose usable range moves with the pitch. See {@link intensitiesFromContrast}.
61
+ */
62
+ contrast: number;
63
+ /** Hemisphere sky color, as a CSS hex string. */
64
+ skyColor: string;
65
+ /** Hemisphere ground color, which every downward face receives. */
66
+ groundColor: string;
67
+ /** Directional color, warm against the cool sky so lit faces separate by temperature too. */
68
+ directionalColor: string;
69
+ }
70
+ /**
71
+ * Scene lighting: a hemisphere fill along the plan's up axis, and a warm directional light at a
72
+ * fixed pitch on the camera's heading. The one intensity control is `contrast`, and `Lighting`
73
+ * derives both `π`-unit intensities from it every frame — so an up-facing surface (a roof) lands on
74
+ * its authored color and no surface the design accounts for clips.
75
+ *
76
+ * That is also why the renderer skips tone mapping (see `Renderer.tsx` and `ExternalCanvas.tsx`): a
77
+ * curve brings an unbounded light sum back into range, and `contrast` keeps this rig's sum in range
78
+ * to begin with, at every pitch.
79
+ *
80
+ * Only lit materials see the lights — solid shape batches and models. Every 2D primitive draws
81
+ * through a `MeshBasicMaterial`, so lines, text and images are unaffected.
82
+ *
83
+ * **The lights sit inside the scene's world matrix**, so every direction below is in the plan's own
84
+ * space, taken there through {@link Space.matrixInverse}. Three reads a light's direction from where
85
+ * it ends up in the *world*, so the two transforms cancel and the light points where it was aimed
86
+ * under any fit. The directional light needs its target under the same matrix for that, since its
87
+ * direction is the difference of two world positions.
88
+ * @param props - The values a caller set, any subset; see {@link LightingProps}.
89
+ * @returns The two lights, and the directional light's target.
90
+ */
91
+ export declare function Lighting(props: LightingProps): import("react").JSX.Element;
92
+ //# sourceMappingURL=Lighting.d.ts.map