@expofp/renderer 3.38.0 → 3.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) 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/objects/containers/hooks.d.ts +7 -2
  28. package/dist/objects/containers/hooks.js +20 -5
  29. package/dist/objects/containers/mesh-writes.js +3 -3
  30. package/dist/objects/images/ImageInstance.js +103 -35
  31. package/dist/objects/images/Images.d.ts +8 -7
  32. package/dist/objects/images/Images.js +94 -28
  33. package/dist/objects/images/image-container.d.ts +14 -3
  34. package/dist/objects/images/image-container.js +19 -5
  35. package/dist/objects/layer/Layer.d.ts +7 -6
  36. package/dist/objects/layer/Layer.js +40 -30
  37. package/dist/objects/layer/layer-context.d.ts +41 -0
  38. package/dist/objects/layer/layer-context.js +45 -0
  39. package/dist/objects/materials/ColorMaterial.js +16 -3
  40. package/dist/objects/materials/LineMaterial.js +4 -2
  41. package/dist/objects/materials/TextureMaterial.js +4 -2
  42. package/dist/objects/materials/effects/color-dim.d.ts +1 -10
  43. package/dist/objects/materials/effects/color-dim.js +4 -12
  44. package/dist/objects/materials/effects/effects-context.d.ts +6 -5
  45. package/dist/objects/materials/shared.d.ts +18 -0
  46. package/dist/objects/materials/shared.js +21 -0
  47. package/dist/objects/models/Models.js +1 -8
  48. package/dist/objects/shapes/Shapes.js +2 -9
  49. package/dist/objects/text/TextInstance.js +1 -1
  50. package/dist/objects/text/text-layout.d.ts +13 -4
  51. package/dist/objects/text/text-layout.js +30 -4
  52. package/dist/space/Space.d.ts +15 -0
  53. package/dist/space/Space.js +16 -10
  54. package/dist/types/defs.d.ts +37 -0
  55. package/dist/utils/logging.d.ts +6 -3
  56. package/dist/utils/logging.js +15 -7
  57. package/dist/utils/math.d.ts +11 -0
  58. package/dist/utils/math.js +14 -0
  59. 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
@@ -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}).
@@ -1,24 +1,29 @@
1
1
  import { useThree } from '@react-three/fiber';
2
- import { use, useEffect, useRef, useState, useSyncExternalStore } from 'react';
2
+ import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
3
3
  import { registerHitResolver } from '../../events/pointer';
4
4
  import { registerSlotWriter, subscribeDef, syncNow, versionOf } from '../../update';
5
- import { LayerDimContext } from '../materials/effects/color-dim';
5
+ import { useLayerDim } 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
24
  // Only whether the layer is dimmed, not by how much: the order pass splits two ways, so an eased
20
25
  // dim re-runs it as its value crosses zero rather than on every frame of the ease.
21
- const layerDimmed = use(LayerDimContext) > 0;
26
+ const layerDimmed = useLayerDim() > 0;
22
27
  const invalidate = useThree((state) => state.invalidate);
23
28
  // Paint order for the whole membership, one pass per structural change. Child effects run before
24
29
  // this one, so every instance the change mounted has already claimed its slot; defs whose
@@ -33,6 +38,16 @@ export function useContainer(create, defs) {
33
38
  // the container answers which field of an intersection names its slot, so nothing here has to know
34
39
  // one mesh kind from another.
35
40
  useEffect(() => registerHitResolver(container.mesh, (hit) => container.defFromHit(hit)), [container]);
41
+ // Assigned after mount rather than passed to `create`, because the container is built once and
42
+ // outlives every render, so it cannot close over a fresh callback. A caller with nothing to run on
43
+ // a mismatch passes no handler, and this then does nothing: no call into the container and no
44
+ // cleanup closure either.
45
+ useEffect(() => {
46
+ if (!onKindMismatch)
47
+ return;
48
+ container.setKindMismatchHandler(onKindMismatch);
49
+ return () => container.setKindMismatchHandler(undefined);
50
+ }, [container, onKindMismatch]);
36
51
  return container;
37
52
  }
38
53
  /**
@@ -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();
@@ -1,5 +1,7 @@
1
1
  import { memo } from 'react';
2
- import { Matrix4 } from 'three';
2
+ import { Matrix4, Vector2, Vector3 } from 'three';
3
+ import { isBatchedSprites } from '../../core/sprites/BatchedSprites';
4
+ import { Y_DOWN_FLIP } from '../../space/Space';
3
5
  import { shapeToMatrix, useDefSync, writeMatrixIfChanged } from '../containers';
4
6
  /**
5
7
  * Keeps one image placement current: claims a slot on mount (where the container writes the fixed
@@ -19,65 +21,131 @@ import { shapeToMatrix, useDefSync, writeMatrixIfChanged } from '../containers';
19
21
  export const ImageInstance = memo(function ImageInstance(props) {
20
22
  const { def, packed, container } = props;
21
23
  useDefSync(container, def, packed, (slot) => {
22
- writeImageMatrix(container.mesh, slot, def, packed);
24
+ const tile = tileRect(packed);
25
+ writeImageMatrix(container, slot, def, tile);
26
+ writeImageAnchor(container, slot, def, tile);
27
+ // Checked on every sync rather than behind the write, because screenSpace is an ordinary def
28
+ // field. Flipping it reaches this instance and stops there, so this is where a mesh learns that
29
+ // it holds something it should not. The atlas re-filters, which never touches the packing.
30
+ if (!!def.screenSpace !== container.facesCamera)
31
+ container.reportKindMismatch();
23
32
  });
24
33
  return null;
25
34
  });
26
35
  /**
27
- * Writes an image placement's transform into its instance, skipping the write when nothing moved.
36
+ * Composes an image placement's instance matrix, and skips the write when nothing moved.
28
37
  *
29
- * `bounds` is a rect, so the quad's placement composes exactly as a rect shape's does; the image's
30
- * own offsets are then post-multiplied onto it, innermost last. The origin shifts the quad within
31
- * its own unit square so the anchor — not the quad's center — lands on `bounds.center`, which also
32
- * makes the anchor the point rotation pivots about. A split source draws as several instances, so
33
- * this instance's quad is finally shrunk onto the fraction of the image its tile covers.
34
- * @param mesh - The mesh holding the instance.
38
+ * `bounds` is a rect, so the quad's placement composes exactly as a rect shape's does. The two
39
+ * kinds part over the anchor and the tile.
40
+ *
41
+ * A flat quad folds the anchor into the matrix. That shifts the quad within its own unit square so
42
+ * the anchor, not the quad's center, lands on the bounds center, which also makes the anchor the
43
+ * point rotation pivots about. A tile then shrinks the quad onto the fraction of the image it covers.
44
+ *
45
+ * A sprite cannot fold the anchor in. It pivots about its anchor, so the matrix translation has to
46
+ * be the bounds center and stay there for every tile of a split source. Only the tile's scale and
47
+ * the plan-to-view y mirror stay in the matrix here. The anchor itself is
48
+ * {@link writeImageAnchor}'s write, not this one's.
49
+ * @param container - The container holding the instance, which says which kind it draws.
35
50
  * @param slot - The instance's slot.
36
51
  * @param def - The image def supplying the transform.
37
- * @param packed - This instance's placement, whose `source` region marks it as a tile.
52
+ * @param tile - This instance's tile within the source, or `undefined` for an unsplit image.
38
53
  */
39
- function writeImageMatrix(mesh, slot, def, packed) {
40
- // The shared scratch `shapeToMatrix` returns, adjusted in place below and uploaded before any
41
- // other write can claim it.
54
+ function writeImageMatrix(container, slot, def, tile) {
42
55
  const matrix = shapeToMatrix(def.bounds);
43
- // Unit-plane axes run the same way as image axes — x right, y down, matching the atlas uv and the
44
- // scene's y-down coordinates — so an origin of `[0, 0]` (top-left) shifts the quad by a positive
45
- // half in both, putting its top-left corner on the bounds' center.
56
+ if (container.facesCamera) {
57
+ if (tile)
58
+ matrix.scale(_tileScale.set(tile.width, tile.height, 1));
59
+ // Y_DOWN_FLIP is the plan-to-view y mirror for this quad's own 2D linear map. Order does not
60
+ // matter against the tile scale above: the flip lands on the left of the whole linear map
61
+ // either way. Premultiplying also flips the translation, so save it and put it back. The
62
+ // translation is the bounds center the sprite pivots about and must not move.
63
+ const centerY = matrix.elements[13];
64
+ matrix.premultiply(Y_DOWN_FLIP);
65
+ matrix.elements[13] = centerY;
66
+ }
67
+ else {
68
+ const [originX, originY] = def.origin ?? DEFAULT_ORIGIN;
69
+ // The anchor as an offset from the quad's middle, which is the convention both paths use.
70
+ const anchorX = originX - 0.5;
71
+ const anchorY = originY - 0.5;
72
+ // Unit-plane axes run the same way as image axes, x right and y down, matching the atlas uv and
73
+ // the scene's y-down coordinates. So an origin of [0, 0], the top-left corner, shifts the quad
74
+ // by a positive half in both and puts its top-left corner on the bounds center.
75
+ matrix.multiply(_anchor.makeTranslation(-anchorX, -anchorY, 0));
76
+ if (tile) {
77
+ matrix.multiply(_tile.makeScale(tile.width, tile.height, 1).setPosition(tile.x, tile.y, 0));
78
+ }
79
+ }
80
+ writeMatrixIfChanged(container.mesh, slot, matrix);
81
+ }
82
+ /**
83
+ * Writes an image placement's anchor uniform, for the one kind that has one, skipping the write when
84
+ * it did not move.
85
+ *
86
+ * Only a sprite pivots about its anchor. The flat path folds the anchor into the matrix instead (see
87
+ * {@link writeImageMatrix}) and has no anchor uniform to write. The mesh is narrowed here rather
88
+ * than asked through `facesCamera`, which answers the same question but cannot narrow the mesh's
89
+ * type for the two anchor calls. A tile shifts the anchor by the tile's own offset from the image's
90
+ * middle and rescales it into the tile's unit square, so the result still reads as an offset from
91
+ * the tile's own middle.
92
+ *
93
+ * `setAnchorAt` invalidates the mesh's bounds on every call, so the compare-and-skip is what keeps
94
+ * a def whose anchor did not move from paying for that invalidation on every sync, the same way
95
+ * `writeMatrixIfChanged` does for the matrix.
96
+ * @param container - The container holding the instance.
97
+ * @param slot - The instance's slot.
98
+ * @param def - The image def supplying the origin.
99
+ * @param tile - This instance's tile within the source, or `undefined` for an unsplit image.
100
+ */
101
+ function writeImageAnchor(container, slot, def, tile) {
102
+ const mesh = container.mesh;
103
+ if (!isBatchedSprites(mesh))
104
+ return;
46
105
  const [originX, originY] = def.origin ?? DEFAULT_ORIGIN;
47
- matrix.multiply(_origin.makeTranslation(0.5 - originX, 0.5 - originY, 0));
48
- if (packed.source)
49
- applyTile(matrix, packed);
50
- writeMatrixIfChanged(mesh, slot, matrix);
106
+ // The anchor as an offset from the quad's middle, which is the convention both paths use.
107
+ let x = originX - 0.5;
108
+ let y = originY - 0.5;
109
+ if (tile) {
110
+ x = (x - tile.x) / tile.width;
111
+ y = (y - tile.y) / tile.height;
112
+ }
113
+ mesh.getAnchorAt(slot, _previousAnchor);
114
+ if (_previousAnchor.x === x && _previousAnchor.y === y)
115
+ return;
116
+ mesh.setAnchorAt(slot, x, y);
51
117
  }
52
118
  /**
53
- * Post-multiplies the image transform by the local sub-quad a tile occupies, so a split source's
54
- * tiles reassemble into the whole image. The tile's fraction of the source, in source pixels, is
55
- * also its fraction of the image. So the sub-quad is that same fraction of the unit plane. The
56
- * code scales the sub-quad to the tile's size, then moves it so its center lands on that
57
- * fraction's center point, in unit-plane coordinates.
58
- * @param matrix - The image transform, multiplied in place.
119
+ * Where one tile sits inside the whole image, in unit-quad lengths.
59
120
  * @param packed - This instance's placement, with the tile's region and the source size to divide
60
121
  * by.
122
+ * @returns The tile's size and the offset of its center from the image's middle, or `undefined` for
123
+ * an unsplit placement or a source that has no size to measure against.
61
124
  */
62
- function applyTile(matrix, packed) {
125
+ function tileRect(packed) {
63
126
  const { source, size } = packed;
64
127
  if (!source || size.width <= 0 || size.height <= 0)
65
- return;
128
+ return undefined;
66
129
  const fx0 = source.x / size.width;
67
130
  const fy0 = source.y / size.height;
68
131
  const fx1 = (source.x + source.width) / size.width;
69
132
  const fy1 = (source.y + source.height) / size.height;
70
- _tile
71
- .makeScale(fx1 - fx0, fy1 - fy0, 1)
72
- .setPosition((fx0 + fx1) / 2 - 0.5, (fy0 + fy1) / 2 - 0.5, 0);
73
- matrix.multiply(_tile);
133
+ return {
134
+ width: fx1 - fx0,
135
+ height: fy1 - fy0,
136
+ x: (fx0 + fx1) / 2 - 0.5,
137
+ y: (fy0 + fy1) / 2 - 0.5,
138
+ };
74
139
  }
75
140
  /**
76
141
  * The anchor a def leaves unset: the image's center, so `bounds.center` is where the middle of the
77
142
  * image sits. Matches {@link ImageDef.origin}'s documented default.
78
143
  */
79
144
  const DEFAULT_ORIGIN = [0.5, 0.5];
80
- // Scratch shared by every instance and every write: filled and uploaded within a single
81
- // synchronous statement, so an instance costs a texture write and no allocation.
82
- const _origin = new Matrix4();
145
+ // Scratch shared by every instance and every write. Each is filled and uploaded within a single
146
+ // synchronous statement, so an instance costs a texture write and no allocation. `_previousAnchor`
147
+ // is only ever read back and compared, so a caller still holding the matrix never collides with it.
148
+ const _anchor = new Matrix4();
83
149
  const _tile = new Matrix4();
150
+ const _tileScale = new Vector3();
151
+ const _previousAnchor = new Vector2();
@@ -17,15 +17,16 @@ export interface ImagesProps extends DefsProps<ImageDef> {
17
17
  createCanvas?: CreateAtlasCanvas;
18
18
  }
19
19
  /**
20
- * Draws a collection of images as textured quads, packing their pixels into one or more atlases and
21
- * drawing each atlas as a single `InstancedMesh` — so a layer of many images costs a handful of
22
- * draw calls, not one per image.
20
+ * Draws a collection of images as textured quads, packing their pixels into one or more atlases.
21
+ * Each atlas draws through up to two meshes that share its texture, so a layer of many images still
22
+ * costs a handful of draw calls, not one per image.
23
23
  *
24
24
  * Unlike a shape layer, an image layer can span **several meshes**: bin-packing splits the images
25
- * across as many atlases as their pixels need, and each atlas gets its own mesh, batch, and
26
- * texture (an {@link ImageAtlas}). A def whose source is too big for one atlas is cut into tiles
27
- * that may land on different atlases, so one def can drive several instances — the atlas iterates
28
- * its packed placements, not the defs.
25
+ * across as many atlases as their pixels need, and each atlas gets its own texture drawn through up
26
+ * to two meshes, a flat `InstancedMesh` and a camera-facing `BatchedSprites` (an
27
+ * {@link ImageAtlas}). An unmixed layer still mounts only one mesh. A def whose source is too big
28
+ * for one atlas is cut into tiles that may land on different atlases, so one def can drive several
29
+ * instances. The atlas iterates its packed placements, not the defs.
29
30
  *
30
31
  * Packing has no incremental path, so a change to the **set** of images **repacks from scratch**: a
31
32
  * fresh atlas set under fresh keys, remounting every mesh and instance. A **reorder** of the same
@@ -3,25 +3,27 @@ import { useThree } from '@react-three/fiber';
3
3
  import { use, useEffect, useRef, useState } from 'react';
4
4
  import { CanvasTexture, SRGBColorSpace } from 'three';
5
5
  import { InstancedMesh } from '../../core/instanced/InstancedMesh';
6
+ import { BatchedSprites } from '../../core/sprites/BatchedSprites';
6
7
  import { createLogger } from '../../utils/logging';
7
8
  import { defKey, useContainer } from '../containers';
8
9
  import { TextureMaterial } from '../materials/TextureMaterial';
9
- import { packAtlases } from './atlas-packer';
10
+ import { packAtlases, } from './atlas-packer';
10
11
  import { ImageContainer, UV_OFFSET_UNIFORM } from './image-container';
11
12
  import { ImageInstance } from './ImageInstance';
12
13
  import { contextOf, createAtlasCanvas } from './pack-sources';
13
14
  import { TextureBudgetContext, useResizeFactor } from './texture-budget';
14
15
  const logger = createLogger('images');
15
16
  /**
16
- * Draws a collection of images as textured quads, packing their pixels into one or more atlases and
17
- * drawing each atlas as a single `InstancedMesh` — so a layer of many images costs a handful of
18
- * draw calls, not one per image.
17
+ * Draws a collection of images as textured quads, packing their pixels into one or more atlases.
18
+ * Each atlas draws through up to two meshes that share its texture, so a layer of many images still
19
+ * costs a handful of draw calls, not one per image.
19
20
  *
20
21
  * Unlike a shape layer, an image layer can span **several meshes**: bin-packing splits the images
21
- * across as many atlases as their pixels need, and each atlas gets its own mesh, batch, and
22
- * texture (an {@link ImageAtlas}). A def whose source is too big for one atlas is cut into tiles
23
- * that may land on different atlases, so one def can drive several instances — the atlas iterates
24
- * its packed placements, not the defs.
22
+ * across as many atlases as their pixels need, and each atlas gets its own texture drawn through up
23
+ * to two meshes, a flat `InstancedMesh` and a camera-facing `BatchedSprites` (an
24
+ * {@link ImageAtlas}). An unmixed layer still mounts only one mesh. A def whose source is too big
25
+ * for one atlas is cut into tiles that may land on different atlases, so one def can drive several
26
+ * instances. The atlas iterates its packed placements, not the defs.
25
27
  *
26
28
  * Packing has no incremental path, so a change to the **set** of images **repacks from scratch**: a
27
29
  * fresh atlas set under fresh keys, remounting every mesh and instance. A **reorder** of the same
@@ -107,26 +109,26 @@ export function Images(props) {
107
109
  return (_jsx(_Fragment, { children: packed.atlases.map((atlas) => (_jsx(ImageAtlas, { atlas: atlas, defs: defs, mounted: mounted }, atlasKey(atlas, packed.generation)))) }));
108
110
  }
109
111
  /**
110
- * Draws one atlas as an `InstancedMesh`: the R3F-owned mesh with the atlas texture as its
111
- * `TextureMaterial`, plus one {@link ImageInstance} per placement on the atlas. Its set of images is
112
- * fixed for its lifetime — a repack remounts the whole component — so the batch and texture are
113
- * built once from state and released together on unmount. Paint order is not fixed: a reorder keeps
114
- * this atlas and re-labels its instances through the effect below.
112
+ * Owns one atlas's texture and its flat/sprite split, and draws each non-empty side as its own
113
+ * `AtlasMesh`. The atlas's set of images is fixed for its lifetime. A repack remounts the whole
114
+ * component, so the texture is built once from state and released on unmount.
115
115
  *
116
- * The uploaded texture is not fixed either: a resizable atlas registers with the scene's texture
117
- * budget and re-derives a downscaled copy of its canvas whenever the shared resize factor moves (see
118
- * {@link resizeTexture}). That is a texture re-upload, not a repack — the mesh, container,
119
- * instances, and their normalized uv regions all stand, since uv is invariant to the canvas's pixel
120
- * size.
116
+ * The uploaded texture is not fixed. A resizable atlas registers with the scene's texture budget and
117
+ * re-derives a downscaled copy of its canvas whenever the shared resize factor moves (see
118
+ * {@link resizeTexture}). That is a texture re-upload, not a repack. The meshes, their containers,
119
+ * their instances, and their normalized uv regions all stand, because uv does not depend on the
120
+ * canvas's pixel size.
121
+ *
122
+ * The flat/sprite split is a second kind of state, held here rather than derived every render. A
123
+ * def's `screenSpace` is an ordinary slot write that never re-renders this component, so an
124
+ * `AtlasMesh` calls `repartition` when it finds a member that no longer matches its kind.
121
125
  * @param props - The atlas and the layer's images in paint order.
122
- * @returns The mesh and one instance component per placement.
126
+ * @returns One `AtlasMesh` per non-empty kind, sharing this atlas's texture.
123
127
  */
124
128
  function ImageAtlas(props) {
125
129
  const { atlas, defs } = props;
126
- // Only the placements are staged; the container and the texture are built from the whole atlas.
127
130
  const mounted = props.mounted ?? atlas.images.length;
128
131
  const [texture] = useState(() => makeAtlasTexture(atlas.image));
129
- const container = useContainer(() => new ImageContainer(atlas), defs);
130
132
  const invalidate = useThree((state) => state.invalidate);
131
133
  const budget = use(TextureBudgetContext);
132
134
  const factor = useResizeFactor(budget, atlas);
@@ -157,15 +159,79 @@ function ImageAtlas(props) {
157
159
  const scaled = scaledRef.current;
158
160
  return () => closeScaled(scaled);
159
161
  }, []);
160
- // `args` is a frozen snapshot so R3F never rebuilds the mesh; the `ref` arrow (not
161
- // `ref={container.bind}`) keeps the React Compiler from inferring `container` itself as a ref and
162
+ // Held in state rather than derived each render, which is the shape `Shapes` uses for its own
163
+ // split. screenSpace is an ordinary slot write that never re-renders this component, so a def
164
+ // crossing the orientation boundary would otherwise stay in a mesh that no longer suits it.
165
+ // repartition is what an instance's reportKindMismatch runs. The atlas's placements never change
166
+ // identity, because a repack remounts this whole component, so nothing else triggers a re-split.
167
+ const [partition, setPartition] = useState(() => partitionImages(atlas.images));
168
+ /** Re-splits the atlas's placements, for a mesh that found a member of the wrong kind. */
169
+ function repartition() {
170
+ setPartition(() => partitionImages(atlas.images));
171
+ }
172
+ // One mesh per kind, keyed so React never reconciles a flat mesh element against a sprite one
173
+ // when the first kind empties out. An empty subset renders nothing, so an unmixed layer costs
174
+ // exactly what it costs today and a mixed layer costs one extra draw call per atlas.
175
+ //
176
+ // renderOrder is the kind's place in IMAGE_KINDS, which makes "flats, then sprites" an explicit
177
+ // rule within a layer. Today the atlas meshes carry no order at all and it falls out of
178
+ // material.id, which is mount order. What that costs is that a flat def and a sprite def in one
179
+ // layer can no longer interleave in array order. `overlay` is there for a caller who needs strict
180
+ // separation, and a camera-facing pin is generally meant to be on top anyway.
181
+ return (_jsx(_Fragment, { children: IMAGE_KINDS.map((kind, renderOrder) => {
182
+ const placements = partition[kind];
183
+ if (placements.length === 0)
184
+ return null;
185
+ return (_jsx(AtlasMesh, { kind: kind, placements: placements, defs: defs, mounted: mounted, capacity: atlas.images.length, texture: texture, renderOrder: renderOrder, onKindMismatch: repartition }, kind));
186
+ }) }));
187
+ }
188
+ /**
189
+ * One atlas mesh and its instances: the atlas's flat images or its camera-facing ones, never both.
190
+ *
191
+ * The material is a child and is genuinely per mesh. `InstancedMeshImpl` wraps whatever it draws with
192
+ * in a closure over its own textures, and two instanced meshes on one material would both render
193
+ * against the first one's. The texture is shared, and that is the whole of the two-mesh split: an
194
+ * atlas is a texture packing and a mesh is a draw call, and nothing forces them to be one thing.
195
+ *
196
+ * The sprite mesh mounts no plane geometry child. `BatchedSpritesImpl` seeds the shared quad
197
+ * itself, and not rendering a replacement is what makes the seeded one stand.
198
+ * @param props - The kind, its placements, and the shared texture.
199
+ * @returns The mesh and one instance component per placement.
200
+ */
201
+ function AtlasMesh(props) {
202
+ const { kind, placements, defs, capacity, texture, renderOrder, onKindMismatch } = props;
203
+ // Handed to this mesh as a prefix of its own placements rather than apportioned between the two,
204
+ // as Shapes does for its batches. A slice can overshoot the scheduler's budget by a factor of two
205
+ // at worst, and the scheduler measures what a slice actually cost and sizes the next one from it.
206
+ const mounted = props.mounted ?? placements.length;
207
+ // Sized from the atlas's whole placement count, not this subset's, so a re-split never resizes a
208
+ // buffer. The container is bookkeeping only, so the copy StrictMode's double-invoked initializer
209
+ // discards is plain garbage.
210
+ const container = useContainer(() => new ImageContainer(capacity), defs, onKindMismatch);
211
+ // args is a frozen snapshot, so R3F never rebuilds the mesh. The ref arrow, rather than
212
+ // ref={container.bind}, keeps the React Compiler from inferring container itself as a ref and
162
213
  // rejecting its render reads.
163
- // The quad and the material are children so R3F creates, attaches, and disposes both — the args
164
- // pair are placeholders the constructor demands (see `ImageMeshArgs`). The material has to be per
165
- // mesh: `InstancedMeshImpl` wraps whatever it draws with in a closure over its own textures, and two
166
- // instanced meshes on one material would both render against the first one's.
167
- return (_jsxs(_Fragment, { children: [_jsxs(InstancedMesh, { args: container.args, ref: (mesh) => container.bind(mesh), sortObjects: true, customSort: container.customSort, perObjectFrustumCulled: true, children: [_jsx("planeGeometry", {}), _jsx(TextureMaterial, { map: texture, uvOffset: UV_OFFSET_UNIFORM })] }), atlas.images.slice(0, mounted).map((packed) => (_jsx(ImageInstance, { def: packed.def, packed: packed, container: container }, `${defKey(packed.def)}:${packed.uv.u0}:${packed.uv.v0}`)))] }));
214
+ const mesh = kind === 'sprite' ? (_jsx(BatchedSprites, { args: container.args, ref: (instance) => container.bind(instance), sortObjects: true, customSort: container.customSort, perObjectFrustumCulled: true, renderOrder: renderOrder, children: _jsx(TextureMaterial, { map: texture, uvOffset: UV_OFFSET_UNIFORM }) })) : (_jsxs(InstancedMesh, { args: container.args, ref: (instance) => container.bind(instance), sortObjects: true, customSort: container.customSort, perObjectFrustumCulled: true, renderOrder: renderOrder, children: [_jsx("planeGeometry", {}), _jsx(TextureMaterial, { map: texture, uvOffset: UV_OFFSET_UNIFORM })] }));
215
+ return (_jsxs(_Fragment, { children: [mesh, placements.slice(0, mounted).map((packed) => (_jsx(ImageInstance, { def: packed.def, packed: packed, container: container }, `${defKey(packed.def)}:${packed.uv.u0}:${packed.uv.v0}`)))] }));
216
+ }
217
+ /**
218
+ * Splits an atlas's placements by orientation.
219
+ *
220
+ * A filter over an array the component already holds. The packing, the canvas, the uv regions and the
221
+ * texture budget are all untouched, so nothing repacks. That is what makes flipping a def's
222
+ * screenSpace cheaper here than a shape's kind change, where a re-split rebuilds partitions.
223
+ * @param images - The atlas's placements, in packing order.
224
+ * @returns The two subsets, each still in packing order.
225
+ */
226
+ function partitionImages(images) {
227
+ const flat = [];
228
+ const sprite = [];
229
+ for (const packed of images)
230
+ (packed.def.screenSpace === true ? sprite : flat).push(packed);
231
+ return { flat, sprite };
168
232
  }
233
+ /** The atlas's two meshes, in draw order: flats first, then the camera-facing ones. */
234
+ const IMAGE_KINDS = ['flat', 'sprite'];
169
235
  /**
170
236
  * Whether two snapshots hold the same *set* of images, order aside — the question "does this need a
171
237
  * repack". Order is excluded on purpose: packing reads only pixels, so a reorder produces the same