@expofp/renderer 3.37.1 → 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.
- package/README.md +34 -0
- package/dist/controls/camera-api.d.ts +28 -0
- package/dist/controls/camera-api.js +33 -10
- package/dist/controls/hooks.js +2 -0
- package/dist/core/InstanceUniforms.d.ts +15 -0
- package/dist/core/InstanceUniforms.js +32 -0
- package/dist/core/batch/BatchedMesh.js +6 -0
- package/dist/core/instanced/instanced-material.js +5 -1
- package/dist/core/lines/BatchedLines.d.ts +9 -7
- package/dist/core/lines/BatchedLines.js +9 -7
- package/dist/core/lines/{FrustumCulling.d.ts → FrustumCullingLines.d.ts} +7 -6
- package/dist/core/lines/{FrustumCulling.js → FrustumCullingLines.js} +2 -2
- package/dist/core/lines/{Raycasting.d.ts → RaycastingLines.d.ts} +7 -6
- package/dist/core/lines/{Raycasting.js → RaycastingLines.js} +2 -2
- package/dist/core/lines/lines-material.js +10 -1
- package/dist/core/sprites/BatchedSprites.d.ts +115 -0
- package/dist/core/sprites/BatchedSprites.js +205 -0
- package/dist/core/sprites/RaycastingSprites.d.ts +41 -0
- package/dist/core/sprites/RaycastingSprites.js +71 -0
- package/dist/core/sprites/sprite-material.d.ts +59 -0
- package/dist/core/sprites/sprite-material.js +192 -0
- package/dist/core/text/BatchedText.d.ts +6 -0
- package/dist/core/text/BatchedText.js +6 -0
- package/dist/core/text/MemberTexture.js +55 -1
- package/dist/core/text/Text.d.ts +16 -1
- package/dist/core/text/Text.js +15 -0
- package/dist/core/text/batched-text-material.js +4 -2
- package/dist/objects/containers/hooks.d.ts +7 -2
- package/dist/objects/containers/hooks.js +20 -5
- package/dist/objects/containers/mesh-writes.js +3 -3
- package/dist/objects/images/ImageInstance.js +111 -43
- package/dist/objects/images/Images.d.ts +19 -7
- package/dist/objects/images/Images.js +266 -83
- package/dist/objects/images/atlas-packer.d.ts +92 -91
- package/dist/objects/images/atlas-packer.js +230 -258
- package/dist/objects/images/image-container.d.ts +14 -3
- package/dist/objects/images/image-container.js +19 -5
- package/dist/objects/images/pack-sources.d.ts +190 -0
- package/dist/objects/images/pack-sources.js +272 -0
- package/dist/objects/images/texture-budget.js +2 -2
- package/dist/objects/images/worker/atlas-worker.d.ts +56 -0
- package/dist/objects/images/worker/atlas-worker.js +123 -0
- package/dist/objects/images/worker/client.d.ts +62 -0
- package/dist/objects/images/worker/client.js +249 -0
- package/dist/objects/images/worker/index.d.ts +10 -0
- package/dist/objects/images/worker/index.js +1 -0
- package/dist/objects/layer/Layer.d.ts +7 -6
- package/dist/objects/layer/Layer.js +40 -30
- package/dist/objects/layer/layer-context.d.ts +41 -0
- package/dist/objects/layer/layer-context.js +45 -0
- package/dist/objects/materials/ColorMaterial.js +16 -3
- package/dist/objects/materials/LineMaterial.js +4 -2
- package/dist/objects/materials/TextureMaterial.js +14 -2
- package/dist/objects/materials/effects/color-dim.d.ts +1 -10
- package/dist/objects/materials/effects/color-dim.js +21 -18
- package/dist/objects/materials/effects/effects-context.d.ts +6 -5
- package/dist/objects/materials/shared.d.ts +18 -0
- package/dist/objects/materials/shared.js +21 -0
- package/dist/objects/models/Models.js +1 -8
- package/dist/objects/shapes/Shapes.js +2 -9
- package/dist/objects/text/TextInstance.js +1 -1
- package/dist/objects/text/text-layout.d.ts +13 -4
- package/dist/objects/text/text-layout.js +30 -4
- package/dist/space/Space.d.ts +15 -0
- package/dist/space/Space.js +16 -10
- package/dist/types/defs.d.ts +53 -2
- package/dist/utils/logging.d.ts +6 -3
- package/dist/utils/logging.js +15 -7
- package/dist/utils/math.d.ts +11 -0
- package/dist/utils/math.js +14 -0
- 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();
|
package/dist/core/text/Text.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/core/text/Text.js
CHANGED
|
@@ -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_${
|
|
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,
|
|
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 {
|
|
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 {
|
|
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,
|
|
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 =
|
|
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
|
|
86
|
-
// statement, so a write costs buffer uploads and no allocation. `_matrix` is the one callers see
|
|
87
|
-
//
|
|
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,7 +1,8 @@
|
|
|
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
|
-
import { imageSourceSize } from './atlas-packer';
|
|
5
6
|
/**
|
|
6
7
|
* Keeps one image placement current: claims a slot on mount (where the container writes the fixed
|
|
7
8
|
* uv region), writes the def's transform and visibility on mount and after every `update(def)`, and
|
|
@@ -20,64 +21,131 @@ import { imageSourceSize } from './atlas-packer';
|
|
|
20
21
|
export const ImageInstance = memo(function ImageInstance(props) {
|
|
21
22
|
const { def, packed, container } = props;
|
|
22
23
|
useDefSync(container, def, packed, (slot) => {
|
|
23
|
-
|
|
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();
|
|
24
32
|
});
|
|
25
33
|
return null;
|
|
26
34
|
});
|
|
27
35
|
/**
|
|
28
|
-
*
|
|
36
|
+
* Composes an image placement's instance matrix, and skips the write when nothing moved.
|
|
29
37
|
*
|
|
30
|
-
* `bounds` is a rect, so the quad's placement composes exactly as a rect shape's does
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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.
|
|
36
50
|
* @param slot - The instance's slot.
|
|
37
51
|
* @param def - The image def supplying the transform.
|
|
38
|
-
* @param
|
|
52
|
+
* @param tile - This instance's tile within the source, or `undefined` for an unsplit image.
|
|
39
53
|
*/
|
|
40
|
-
function writeImageMatrix(
|
|
41
|
-
// The shared scratch `shapeToMatrix` returns, adjusted in place below and uploaded before any
|
|
42
|
-
// other write can claim it.
|
|
54
|
+
function writeImageMatrix(container, slot, def, tile) {
|
|
43
55
|
const matrix = shapeToMatrix(def.bounds);
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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);
|
|
52
81
|
}
|
|
53
82
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* @
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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.
|
|
61
100
|
*/
|
|
62
|
-
function
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
101
|
+
function writeImageAnchor(container, slot, def, tile) {
|
|
102
|
+
const mesh = container.mesh;
|
|
103
|
+
if (!isBatchedSprites(mesh))
|
|
65
104
|
return;
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
105
|
+
const [originX, originY] = def.origin ?? DEFAULT_ORIGIN;
|
|
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);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Where one tile sits inside the whole image, in unit-quad lengths.
|
|
120
|
+
* @param packed - This instance's placement, with the tile's region and the source size to divide
|
|
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.
|
|
124
|
+
*/
|
|
125
|
+
function tileRect(packed) {
|
|
126
|
+
const { source, size } = packed;
|
|
127
|
+
if (!source || size.width <= 0 || size.height <= 0)
|
|
128
|
+
return undefined;
|
|
129
|
+
const fx0 = source.x / size.width;
|
|
130
|
+
const fy0 = source.y / size.height;
|
|
131
|
+
const fx1 = (source.x + source.width) / size.width;
|
|
132
|
+
const fy1 = (source.y + source.height) / size.height;
|
|
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
|
|
81
|
-
// synchronous statement, so an instance costs a texture write and no allocation.
|
|
82
|
-
|
|
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();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ImageDef } from '../../types/defs';
|
|
2
2
|
import { type DefsProps } from '../containers';
|
|
3
|
+
import { type CreateAtlasCanvas } from './atlas-packer';
|
|
3
4
|
/** Props for {@link Images}. */
|
|
4
5
|
export interface ImagesProps extends DefsProps<ImageDef> {
|
|
5
6
|
/**
|
|
@@ -8,17 +9,24 @@ export interface ImagesProps extends DefsProps<ImageDef> {
|
|
|
8
9
|
* value to cap atlas memory below it, or to force splitting deterministically in a test.
|
|
9
10
|
*/
|
|
10
11
|
maxAtlasSize?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Makes each atlas canvas. Defaults to {@link createAtlasCanvas}, which builds an
|
|
14
|
+
* `OffscreenCanvas` wherever that global exists and a DOM canvas otherwise. The prop exists so
|
|
15
|
+
* that a test can pack without a real 2D context. It is not part of the public scene API.
|
|
16
|
+
*/
|
|
17
|
+
createCanvas?: CreateAtlasCanvas;
|
|
11
18
|
}
|
|
12
19
|
/**
|
|
13
|
-
* Draws a collection of images as textured quads, packing their pixels into one or more atlases
|
|
14
|
-
*
|
|
15
|
-
* 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.
|
|
16
23
|
*
|
|
17
24
|
* Unlike a shape layer, an image layer can span **several meshes**: bin-packing splits the images
|
|
18
|
-
* across as many atlases as their pixels need, and each atlas gets its own
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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.
|
|
22
30
|
*
|
|
23
31
|
* Packing has no incremental path, so a change to the **set** of images **repacks from scratch**: a
|
|
24
32
|
* fresh atlas set under fresh keys, remounting every mesh and instance. A **reorder** of the same
|
|
@@ -30,6 +38,10 @@ export interface ImagesProps extends DefsProps<ImageDef> {
|
|
|
30
38
|
* index) but is best-effort **across** atlases: separate meshes draw in atlas order, which packing
|
|
31
39
|
* decides, not paint order. Overlapping images in one layer that pack onto different atlases is the
|
|
32
40
|
* one case this does not order — rare for the markers, dots, and logos an image layer holds.
|
|
41
|
+
*
|
|
42
|
+
* Packing is asynchronous, so a layer draws nothing until its first pack settles, which is one
|
|
43
|
+
* microtask after the defs arrive. A repack that supersedes a pack still in flight aborts that
|
|
44
|
+
* pack, and closes whatever it produced.
|
|
33
45
|
* @param props - The images to draw.
|
|
34
46
|
* @returns One {@link ImageAtlas} per packed atlas.
|
|
35
47
|
*/
|