@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
@@ -1,4 +1,5 @@
1
- import { Color, DataTexture, FloatType, Frustum, Matrix4, RGBAFormat, Sphere, } from 'three';
1
+ import { Color, DataTexture, FloatType, Frustum, Matrix4, RGBAFormat, Sphere, Vector3, } from 'three';
2
+ import { Y_DOWN_FLIP } from '../../space/Space';
2
3
  /**
3
4
  * The data texture a batch's shader reads each member's transform and style from: one 32-float row
4
5
  * per member, refilled once per draw with frustum culling folded in.
@@ -43,6 +44,16 @@ export class MemberTexture {
43
44
  .multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse)
44
45
  .multiply(host.matrixWorld);
45
46
  _frustum.setFromProjectionMatrix(_matrix, camera.coordinateSystem);
47
+ // The camera's rotation, brought into the batch's own space, because that is where member
48
+ // matrices live. Computed once per draw, not once per member. `Y_DOWN_FLIP` plays the camera's
49
+ // own rest pose here, so `faceCamera` does not bake that pose in twice: once here and once more
50
+ // in the ordinary view transform at draw time. See `Y_DOWN_FLIP` in `space/Space.ts` for why
51
+ // this role holds regardless of a member's own scale or rotation.
52
+ _basis
53
+ .extractRotation(host.matrixWorld)
54
+ .transpose()
55
+ .multiply(_cameraBasis.extractRotation(camera.matrixWorld))
56
+ .multiply(Y_DOWN_FLIP);
46
57
  // Hoisted out of the loop: `Text`'s `material` getter calls `hasOutline()`, which walks every
47
58
  // member, so reading it per member made this loop quadratic — and it returns an *array* once
48
59
  // outlines are on, whose `.color` is undefined. The material being drawn is already in hand.
@@ -52,6 +63,7 @@ export class MemberTexture {
52
63
  // An unsynced member has no layout to write and draws nothing either.
53
64
  if (!text?.textRenderInfo)
54
65
  continue;
66
+ faceCamera(text, _basis);
55
67
  const start = memberId * FLOATS_PER_MEMBER;
56
68
  if (isDrawn(text)) {
57
69
  this.writeMember(texData, start, text, material, batchColor);
@@ -160,6 +172,43 @@ export class MemberTexture {
160
172
  }
161
173
  }
162
174
  }
175
+ /**
176
+ * Turns a screen-space member to face the camera, and leaves a flat one alone.
177
+ *
178
+ * Composed on the CPU, on a loop that already runs once per member per draw, rather than in the
179
+ * shader. The shader path costs twelve bytes per member of GPU memory and needs a seam on both
180
+ * `BatchedTextImpl.raycast` and `isDrawn`. This path costs neither, because both of those read the
181
+ * member's matrix already. Write the sprite matrix there and both follow.
182
+ *
183
+ * The composition is `T(anchor) . basis . T(position - anchor) . Rz(rotation.z) . S(scale)`. The
184
+ * basis is the camera's rotation brought into the batch's own space, corrected by `Y_DOWN_FLIP`
185
+ * for the camera's own rest pose (see {@link Y_DOWN_FLIP} in `space/Space.ts`). Everything else is
186
+ * read off the member exactly as
187
+ * `updateMatrix` reads it. Straight overhead the basis is the identity and this reduces to the flat
188
+ * matrix, which is the equality the top-down baseline asserts on screen.
189
+ *
190
+ * Written through `setMatrix` only when it changed, so a still camera costs sixteen comparisons and
191
+ * no bounds invalidation. A pan or a zoom costs the same, because neither moves the camera's
192
+ * rotation. Only a roll or a pitch dirties a row.
193
+ * @param text - The member to turn.
194
+ * @param basis - The camera basis for this draw, in the batch's own space.
195
+ */
196
+ function faceCamera(text, basis) {
197
+ const anchor = text.spriteAnchor;
198
+ if (!anchor)
199
+ return;
200
+ // `spriteAnchor` carries no z. The offset's z is zero by construction, and the pivot's z is read
201
+ // off the member's own position instead.
202
+ _offset.set(text.position.x - anchor.x, text.position.y - anchor.y, 0);
203
+ _spriteMatrix
204
+ .copy(basis)
205
+ .multiply(_step.makeTranslation(_offset.x, _offset.y, _offset.z))
206
+ .multiply(_step.makeRotationZ(text.rotation.z))
207
+ .scale(text.scale)
208
+ .premultiply(_step.makeTranslation(anchor.x, anchor.y, text.position.z));
209
+ if (!text.matrix.equals(_spriteMatrix))
210
+ text.setMatrix(_spriteMatrix);
211
+ }
163
212
  /**
164
213
  * Whether a member draws this frame: shown, and inside the frustum this pass was set up with.
165
214
  * @param text - The member to test.
@@ -192,3 +241,8 @@ const _color = new Color();
192
241
  const _matrix = new Matrix4();
193
242
  const _frustum = new Frustum();
194
243
  const _sphere = new Sphere();
244
+ const _basis = new Matrix4();
245
+ const _cameraBasis = new Matrix4();
246
+ const _spriteMatrix = new Matrix4();
247
+ const _step = new Matrix4();
248
+ const _offset = new Vector3();
@@ -1,4 +1,4 @@
1
- import { Matrix4 } from 'three';
1
+ import { Matrix4, type Vector2 } from 'three';
2
2
  import { Text as TroikaText, type TextMaterial } from 'troika-three-text';
3
3
  import { type BatchedTextImpl } from './BatchedText';
4
4
  /**
@@ -45,6 +45,21 @@ export declare class TextImpl extends TroikaText {
45
45
  * this initializer runs, so seeding `false` here would quietly undo them.
46
46
  */
47
47
  _syncFlag: boolean;
48
+ /**
49
+ * Where this member pivots when it faces the camera, in the plan's x and y. `null` draws it flat
50
+ * on the plan.
51
+ *
52
+ * One object, set and cleared together, so the two halves cannot disagree. Deliberately not named
53
+ * `anchorX` or `anchorY`, which troika owns and lists in its syncable properties. Assigning those
54
+ * would re-run layout and repack the batch on every write. `position`, `quaternion`, `scale` and
55
+ * `matrix` are all absent from that list, which is what makes the per-draw compose free of layout.
56
+ *
57
+ * Two dimensional on purpose. Every line of a def sits at the def's own elevation, so the anchor's
58
+ * z always equals the member's own `position.z`. There is no separate z to store.
59
+ *
60
+ * Derived, never authored. `applyPlacement` sets it from the def's bounds.
61
+ */
62
+ spriteAnchor: Vector2 | null;
48
63
  /**
49
64
  * Writes the local transform directly and tells the batch its bounds moved.
50
65
  *
@@ -45,6 +45,21 @@ export class TextImpl extends TroikaText {
45
45
  * this initializer runs, so seeding `false` here would quietly undo them.
46
46
  */
47
47
  _syncFlag = true;
48
+ /**
49
+ * Where this member pivots when it faces the camera, in the plan's x and y. `null` draws it flat
50
+ * on the plan.
51
+ *
52
+ * One object, set and cleared together, so the two halves cannot disagree. Deliberately not named
53
+ * `anchorX` or `anchorY`, which troika owns and lists in its syncable properties. Assigning those
54
+ * would re-run layout and repack the batch on every write. `position`, `quaternion`, `scale` and
55
+ * `matrix` are all absent from that list, which is what makes the per-draw compose free of layout.
56
+ *
57
+ * Two dimensional on purpose. Every line of a def sits at the def's own elevation, so the anchor's
58
+ * z always equals the member's own `position.z`. There is no separate z to store.
59
+ *
60
+ * Derived, never authored. `applyPlacement` sets it from the def's bounds.
61
+ */
62
+ spriteAnchor = null;
48
63
  /**
49
64
  * Writes the local transform directly and tells the batch its bounds moved.
50
65
  *
@@ -56,9 +56,11 @@ export function configureDerivedMaterial(derived, base, uniforms) {
56
56
  // term.) Building it here, with a lower id, restores the intended order; troika's getter finds it
57
57
  // already cached and uses it.
58
58
  derived._outlineMtl = makeOutlineMaterial(derived);
59
- // Recompile when the uniform schema changes, since that changes the generated GLSL.
59
+ // Recompile when the uniform schema changes, since that changes the generated GLSL. The whole
60
+ // schema, not just whether a texture exists: two batches declaring different uniforms compile
61
+ // different source, and three compares nothing but this key.
60
62
  const cacheKeyBase = derived.customProgramCacheKey.bind(derived);
61
- derived.customProgramCacheKey = () => `efpText_${!!uniforms.texture}|${cacheKeyBase()}`;
63
+ derived.customProgramCacheKey = () => `efpText_${uniforms.shaderKey}|${cacheKeyBase()}`;
62
64
  // Assignment, *not* the read-bind-wrap pattern the sibling meshes use: troika defines
63
65
  // `onBeforeCompile` as an accessor pair whose getter returns troika's own handler and whose setter
64
66
  // stashes ours (troika-three-utils DerivedMaterial), so calling through the getter from inside our
@@ -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
  */