@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,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
@@ -18,66 +20,132 @@ import { shapeToMatrix, useDefSync, writeMatrixIfChanged } from '../containers';
18
20
  */
19
21
  export const ImageInstance = memo(function ImageInstance(props) {
20
22
  const { def, packed, container } = props;
21
- useDefSync(container, def, packed, (slot) => {
22
- writeImageMatrix(container.mesh, slot, def, packed);
23
+ useDefSync(container, def, () => packed, (slot) => {
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
@@ -1,7 +1,7 @@
1
1
  import { type InstancedMeshImpl, type InstancedMeshImplParams } from '../../core/instanced/InstancedMesh';
2
2
  import { type ImageDef } from '../../types/defs';
3
3
  import { SlotContainer } from '../containers';
4
- import { type Atlas, type PackedImage } from './atlas-packer';
4
+ import { type PackedImage } from './atlas-packer';
5
5
  /**
6
6
  * The per-instance uniform each atlas mesh reads its texture region from. The container owns the
7
7
  * name: it writes each slot's region here (`setUniformAt`) and hands it to the mesh's
@@ -42,9 +42,20 @@ export declare class ImageContainer extends SlotContainer<PackedImage, Instanced
42
42
  * component renders `<instancedMesh args={container.args}>` with the geometry and material as
43
43
  * children, and hands the mesh back through {@link SlotContainer.bind} — so R3F owns their
44
44
  * lifetimes, including disposal on unmount and survival across StrictMode's simulated remount.
45
- * @param atlas - The packed atlas this container draws; only its image count sizes the mesh.
45
+ * @param capacity - How many instances to size the mesh for. This is the atlas's whole placement
46
+ * count, not one kind's share of it, so moving a def between the two meshes never resizes a
47
+ * buffer. The mesh grows past it on demand in any case.
46
48
  */
47
- constructor(atlas: Atlas);
49
+ constructor(capacity: number);
50
+ /**
51
+ * Whether the bound mesh draws its members facing the camera.
52
+ *
53
+ * Read off the mesh rather than stored, so one field cannot disagree with another. `screenSpace` is
54
+ * an ordinary def field, so flipping it re-renders nothing and only an instance can notice that it
55
+ * is drawing from the wrong mesh.
56
+ * @returns Whether this container holds a sprite batch.
57
+ */
58
+ get facesCamera(): boolean;
48
59
  protected addInstance(packed: PackedImage): number;
49
60
  protected removeInstance(slot: number): void;
50
61
  }
@@ -1,4 +1,5 @@
1
1
  import { Vector4 } from 'three';
2
+ import { isBatchedSprites } from '../../core/sprites/BatchedSprites';
2
3
  import { SlotContainer } from '../containers';
3
4
  import { DIM_SCHEMA } from '../materials/effects/color-dim';
4
5
  /**
@@ -31,11 +32,24 @@ export class ImageContainer extends SlotContainer {
31
32
  * component renders `<instancedMesh args={container.args}>` with the geometry and material as
32
33
  * children, and hands the mesh back through {@link SlotContainer.bind} — so R3F owns their
33
34
  * lifetimes, including disposal on unmount and survival across StrictMode's simulated remount.
34
- * @param atlas - The packed atlas this container draws; only its image count sizes the mesh.
35
+ * @param capacity - How many instances to size the mesh for. This is the atlas's whole placement
36
+ * count, not one kind's share of it, so moving a def between the two meshes never resizes a
37
+ * buffer. The mesh grows past it on demand in any case.
35
38
  */
36
- constructor(atlas) {
39
+ constructor(capacity) {
37
40
  super({ schema: IMAGE_SCHEMA });
38
- this.args = [{ capacity: Math.max(1, atlas.images.length) }];
41
+ this.args = [{ capacity: Math.max(1, capacity) }];
42
+ }
43
+ /**
44
+ * Whether the bound mesh draws its members facing the camera.
45
+ *
46
+ * Read off the mesh rather than stored, so one field cannot disagree with another. `screenSpace` is
47
+ * an ordinary def field, so flipping it re-renders nothing and only an instance can notice that it
48
+ * is drawing from the wrong mesh.
49
+ * @returns Whether this container holds a sprite batch.
50
+ */
51
+ get facesCamera() {
52
+ return isBatchedSprites(this.mesh);
39
53
  }
40
54
  addInstance(packed) {
41
55
  // `addInstances` grows the buffers itself if the atlas ever overflows its sized capacity, so
@@ -65,6 +79,6 @@ export class ImageContainer extends SlotContainer {
65
79
  const IMAGE_SCHEMA = {
66
80
  vertex: { [UV_OFFSET_UNIFORM]: 'vec4', ...DIM_SCHEMA.vertex },
67
81
  };
68
- // Scratch for the one uv-region upload a claim performs, filled and read within a single
69
- // synchronous statement.
82
+ // Scratch for the one uv-region upload a claim performs, filled and read within a single synchronous
83
+ // statement.
70
84
  const _uvOffset = new Vector4();
@@ -5,10 +5,10 @@ export interface LayerProps {
5
5
  /** The layer definition to render, together with its descendants. */
6
6
  layer: LayerDef;
7
7
  /**
8
- * Rendered inside the root group, before the tree's own content — under the root layer's dim
9
- * provider, which is what the scene's background quad mounts here for: the root's dim reaches it
10
- * like any layer's, and its material exists before every layer's, which its draw order relies
11
- * on.
8
+ * Rendered inside the root group, before the tree's own content. This sits under the root
9
+ * layer's dim and overlay providers. The scene's background quad mounts here for that reason:
10
+ * the root's dim reaches it like any layer's, and its material exists before every layer's,
11
+ * which its draw order relies on.
12
12
  */
13
13
  children?: ReactNode;
14
14
  }
@@ -18,8 +18,9 @@ export interface LayerProps {
18
18
  * This root component owns the tree-wide concerns — in debug mode the layer-visibility GUI, a
19
19
  * "Main layers" folder for its direct child layers and a "Content layers" folder mirroring the
20
20
  * scheduler's leaf queue (see `visibility-gui.ts`) — and seeds the recursion {@link LayerNode}
21
- * carries: nothing above the root can hide it, and the whole tree's draw order lives inside
22
- * `[0, 1)`.
21
+ * carries with `hidden`, at `false`. Dim and overlay need no seed: `LayerDimContext` and
22
+ * `LayerOverlayContext` already default to undimmed and plan tier, which is what "no layer above
23
+ * me" means for either one. The whole tree's draw order lives inside `[0, 1)`.
23
24
  * @param props - The layer props.
24
25
  * @returns The tree's root group.
25
26
  */