@driftengine/ui2d 3.62.0 → 4.0.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 (55) hide show
  1. package/NOTICE +20 -0
  2. package/README.md +28 -1
  3. package/dist/a11y.d.ts +25 -0
  4. package/dist/a11y.js +48 -0
  5. package/dist/index.d.ts +21 -0
  6. package/dist/index.js +19 -0
  7. package/dist/richText.d.ts +25 -0
  8. package/dist/richText.js +49 -0
  9. package/dist/spriteGl.js +19 -3
  10. package/dist/spriteGpu.d.ts +5 -0
  11. package/dist/spriteGpu.js +63 -2
  12. package/dist/spriteTexture.d.ts +30 -0
  13. package/dist/spriteTexture.js +1 -0
  14. package/dist/textHost.d.ts +41 -0
  15. package/dist/textHost.js +44 -0
  16. package/dist/textModel.d.ts +45 -0
  17. package/dist/textModel.js +84 -0
  18. package/dist/theme.d.ts +30 -0
  19. package/dist/theme.js +36 -0
  20. package/dist/uiClip.d.ts +28 -0
  21. package/dist/uiClip.js +46 -0
  22. package/dist/uiDirty.d.ts +47 -0
  23. package/dist/uiDirty.js +116 -0
  24. package/dist/uiLayer.d.ts +18 -0
  25. package/dist/uiLayer.js +30 -0
  26. package/dist/uiLayout.js +29 -2
  27. package/dist/uiNode.d.ts +29 -0
  28. package/dist/uiNode.js +6 -0
  29. package/dist/uiPointer.d.ts +47 -0
  30. package/dist/uiPointer.js +50 -0
  31. package/dist/uiScroll.d.ts +38 -0
  32. package/dist/uiScroll.js +65 -0
  33. package/dist/uiSlice.d.ts +25 -0
  34. package/dist/uiSlice.js +51 -0
  35. package/dist/virtualList.d.ts +24 -0
  36. package/dist/virtualList.js +49 -0
  37. package/package.json +2 -2
  38. package/src/a11y.ts +73 -0
  39. package/src/index.ts +54 -0
  40. package/src/richText.ts +69 -0
  41. package/src/spriteGl.ts +22 -3
  42. package/src/spriteGpu.ts +73 -3
  43. package/src/spriteTexture.ts +31 -0
  44. package/src/textHost.ts +66 -0
  45. package/src/textModel.ts +126 -0
  46. package/src/theme.ts +57 -0
  47. package/src/uiClip.ts +69 -0
  48. package/src/uiDirty.ts +164 -0
  49. package/src/uiLayer.ts +47 -0
  50. package/src/uiLayout.ts +30 -2
  51. package/src/uiNode.ts +35 -0
  52. package/src/uiPointer.ts +84 -0
  53. package/src/uiScroll.ts +90 -0
  54. package/src/uiSlice.ts +90 -0
  55. package/src/virtualList.ts +74 -0
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Moving a clipping node's contents underneath it.
3
+ *
4
+ * **Scrolling is an offset applied at placement, not a transform applied at draw.** `uiLayout.ts`
5
+ * subtracts it from the content origin, so a scrolled child's rectangle simply is where it is —
6
+ * and hit testing, focus and clipping keep working without any of them knowing that scrolling
7
+ * exists. Applied at draw, each of those would need the inverse and one of them would eventually
8
+ * get it wrong in one direction only.
9
+ *
10
+ * **A scroll that moves nothing says so.** Nested containers are the case: a wheel over an inner
11
+ * list that has reached its end should scroll the outer one rather than stopping dead, and a
12
+ * caller that cannot tell "consumed" from "already at the end" cannot implement that. The boolean
13
+ * is the whole mechanism.
14
+ */
15
+ import type { UiNode } from './uiNode.ts';
16
+ /**
17
+ * How far this node's contents extend past its own box, along each axis.
18
+ *
19
+ * Read from the span `layoutUiTree` measured, which is relative to the content origin and so does
20
+ * not change as the node scrolls. **Deriving it from placed rectangles plus the live scroll value
21
+ * is wrong**, and wrong in the one situation it is asked about: after a caller has moved the
22
+ * scroll, the rectangles still reflect the previous value and the two no longer agree.
23
+ */
24
+ export declare function scrollExtent(node: UiNode): {
25
+ x: number;
26
+ y: number;
27
+ };
28
+ export declare function clampScroll(node: UiNode): void;
29
+ /** Scroll, clamped. Returns whether anything actually moved — see the header for why. */
30
+ export declare function scrollBy(node: UiNode, dx: number, dy: number): boolean;
31
+ /**
32
+ * Give a wheel to the innermost container under the pointer that can use it, then outwards.
33
+ *
34
+ * Innermost first is the order a person expects and the opposite of the order the tree makes
35
+ * convenient, so the chain is collected on the way down and walked back up. Returns the node that
36
+ * consumed it, or null when nothing did — which is a caller's signal to let the page have it.
37
+ */
38
+ export declare function routeScrollWheel(root: UiNode, x: number, y: number, dx: number, dy: number): UiNode | null;
@@ -0,0 +1,65 @@
1
+ import { uiRectHolds } from './uiNode.js';
2
+ /**
3
+ * How far this node's contents extend past its own box, along each axis.
4
+ *
5
+ * Read from the span `layoutUiTree` measured, which is relative to the content origin and so does
6
+ * not change as the node scrolls. **Deriving it from placed rectangles plus the live scroll value
7
+ * is wrong**, and wrong in the one situation it is asked about: after a caller has moved the
8
+ * scroll, the rectangles still reflect the previous value and the two no longer agree.
9
+ */
10
+ export function scrollExtent(node) {
11
+ const contentW = Math.max(0, node.rect.w - node.paddingLeft - node.paddingRight);
12
+ const contentH = Math.max(0, node.rect.h - node.paddingTop - node.paddingBottom);
13
+ return {
14
+ x: Math.max(0, node.contentSpanX - contentW),
15
+ y: Math.max(0, node.contentSpanY - contentH),
16
+ };
17
+ }
18
+ export function clampScroll(node) {
19
+ const extent = scrollExtent(node);
20
+ node.scrollX = Math.min(Math.max(0, node.scrollX), extent.x);
21
+ node.scrollY = Math.min(Math.max(0, node.scrollY), extent.y);
22
+ }
23
+ /** Scroll, clamped. Returns whether anything actually moved — see the header for why. */
24
+ export function scrollBy(node, dx, dy) {
25
+ const beforeX = node.scrollX;
26
+ const beforeY = node.scrollY;
27
+ node.scrollX += dx;
28
+ node.scrollY += dy;
29
+ clampScroll(node);
30
+ return node.scrollX !== beforeX || node.scrollY !== beforeY;
31
+ }
32
+ /* Reused across calls: this runs per wheel event and the chain is its only allocation. */
33
+ const CHAIN = [];
34
+ /**
35
+ * Give a wheel to the innermost container under the pointer that can use it, then outwards.
36
+ *
37
+ * Innermost first is the order a person expects and the opposite of the order the tree makes
38
+ * convenient, so the chain is collected on the way down and walked back up. Returns the node that
39
+ * consumed it, or null when nothing did — which is a caller's signal to let the page have it.
40
+ */
41
+ export function routeScrollWheel(root, x, y, dx, dy) {
42
+ let depth = 0;
43
+ let walk = root;
44
+ while (walk !== null) {
45
+ CHAIN[depth] = walk;
46
+ depth += 1;
47
+ let next = null;
48
+ for (let i = walk.children.length - 1; i >= 0; i -= 1) {
49
+ const child = walk.children[i];
50
+ if (!child.hidden && uiRectHolds(child, x, y)) {
51
+ next = child;
52
+ break;
53
+ }
54
+ }
55
+ walk = next;
56
+ }
57
+ for (let i = depth - 1; i >= 0; i -= 1) {
58
+ const node = CHAIN[i];
59
+ if (!node.clip)
60
+ continue;
61
+ if (scrollBy(node, dx, dy))
62
+ return node;
63
+ }
64
+ return null;
65
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A panel background that stretches without stretching its corners.
3
+ *
4
+ * Nine cells: the four corners keep their source size, the four edges stretch along one axis, and
5
+ * the middle stretches along both. Written into a caller's array as
6
+ * `x, y, w, h, u0, v0, u1, v1` per cell, in row-major order — top-left, top, top-right, left,
7
+ * middle, right, bottom-left, bottom, bottom-right — so the middle is always index 4.
8
+ *
9
+ * **A box narrower than its own insets scales them down rather than letting them overlap.**
10
+ * Overlapping corners draw the same alpha twice, which on a translucent panel is a visible seam
11
+ * exactly where the design was trying to be careful.
12
+ *
13
+ * **The source size is a parameter because `SpriteFrame` carries only texture coordinates.** The
14
+ * insets are in texels, so splitting the frame needs to know what the frame's texels span.
15
+ */
16
+ import type { SpriteFrame } from './spriteSheet.ts';
17
+ /** Floats per placement: x, y, w, h, u0, v0, u1, v1. */
18
+ export declare const SLICE_STRIDE = 8;
19
+ export interface SliceInset {
20
+ readonly left: number;
21
+ readonly top: number;
22
+ readonly right: number;
23
+ readonly bottom: number;
24
+ }
25
+ export declare function sliceInto(out: Float32Array, frame: SpriteFrame, sourceWidth: number, sourceHeight: number, inset: SliceInset, x: number, y: number, w: number, h: number): number;
@@ -0,0 +1,51 @@
1
+ /** Floats per placement: x, y, w, h, u0, v0, u1, v1. */
2
+ export const SLICE_STRIDE = 8;
3
+ export function sliceInto(out, frame, sourceWidth, sourceHeight, inset, x, y, w, h) {
4
+ /*
5
+ * Scaled down together when the box cannot hold both, so the two corners meet exactly at the
6
+ * midpoint instead of overlapping. The texture coordinates are deliberately *not* scaled with
7
+ * them: the corner draws a smaller piece of the box out of the same piece of the source, which
8
+ * is squashing rather than seaming and is the better of the two wrong pictures available.
9
+ */
10
+ const sideX = inset.left + inset.right;
11
+ const kx = sideX > w && sideX > 0 ? w / sideX : 1;
12
+ const left = inset.left * kx;
13
+ const right = inset.right * kx;
14
+ const sideY = inset.top + inset.bottom;
15
+ const ky = sideY > h && sideY > 0 ? h / sideY : 1;
16
+ const top = inset.top * ky;
17
+ const bottom = inset.bottom * ky;
18
+ const colX = [x, x + left, x + w - right];
19
+ const colW = [left, Math.max(0, w - left - right), right];
20
+ const rowY = [y, y + top, y + h - bottom];
21
+ const rowH = [top, Math.max(0, h - top - bottom), bottom];
22
+ const du = frame.u1 - frame.u0;
23
+ const dv = frame.v1 - frame.v0;
24
+ const uAt = [
25
+ frame.u0,
26
+ frame.u0 + (inset.left / sourceWidth) * du,
27
+ frame.u1 - (inset.right / sourceWidth) * du,
28
+ frame.u1,
29
+ ];
30
+ const vAt = [
31
+ frame.v0,
32
+ frame.v0 + (inset.top / sourceHeight) * dv,
33
+ frame.v1 - (inset.bottom / sourceHeight) * dv,
34
+ frame.v1,
35
+ ];
36
+ let at = 0;
37
+ for (let r = 0; r < 3; r += 1) {
38
+ for (let c = 0; c < 3; c += 1) {
39
+ out[at] = colX[c];
40
+ out[at + 1] = rowY[r];
41
+ out[at + 2] = colW[c];
42
+ out[at + 3] = rowH[r];
43
+ out[at + 4] = uAt[c];
44
+ out[at + 5] = vAt[r];
45
+ out[at + 6] = uAt[c + 1];
46
+ out[at + 7] = vAt[r + 1];
47
+ at += SLICE_STRIDE;
48
+ }
49
+ }
50
+ return 9;
51
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Which rows a scroll offset makes visible, so that a list costs a screenful rather than a list.
3
+ *
4
+ * An asset browser with ten thousand entries has to cost what a screenful costs. Without this the
5
+ * scene tree is unusable the first time somebody opens a real project, which is the moment nobody
6
+ * wants to discover it.
7
+ *
8
+ * **Overscan is applied before clamping, not after.** Applied after, a window near either end
9
+ * runs past the row count and a caller indexes rows that do not exist.
10
+ */
11
+ export interface RowWindow {
12
+ first: number;
13
+ count: number;
14
+ }
15
+ /** Uniform row heights, which is arithmetic. */
16
+ export declare function visibleRange(scrollY: number, viewHeight: number, rowHeight: number, rowCount: number, overscan: number): RowWindow;
17
+ /**
18
+ * Variable row heights, through a prefix-sum table of row tops the caller owns.
19
+ *
20
+ * **The caller owns the table** because only the caller knows when a row's height changed, and
21
+ * recomputing it here every frame would be the cost this module exists to avoid. `offsets` holds
22
+ * `rowCount + 1` entries: the top of each row, then the bottom of the last.
23
+ */
24
+ export declare function visibleRangeVariable(scrollY: number, viewHeight: number, offsets: Float64Array, rowCount: number, overscan: number): RowWindow;
@@ -0,0 +1,49 @@
1
+ function clampWindow(first, lastExclusive, rowCount) {
2
+ const lo = Math.max(0, Math.min(rowCount, first));
3
+ const hi = Math.max(lo, Math.min(rowCount, lastExclusive));
4
+ return { first: lo, count: hi - lo };
5
+ }
6
+ /** Uniform row heights, which is arithmetic. */
7
+ export function visibleRange(scrollY, viewHeight, rowHeight, rowCount, overscan) {
8
+ if (rowHeight <= 0 || rowCount <= 0)
9
+ return { first: 0, count: 0 };
10
+ const top = Math.max(0, scrollY);
11
+ const first = Math.floor(top / rowHeight) - overscan;
12
+ const lastExclusive = Math.ceil((top + viewHeight) / rowHeight) + overscan;
13
+ return clampWindow(first, lastExclusive, rowCount);
14
+ }
15
+ /**
16
+ * Variable row heights, through a prefix-sum table of row tops the caller owns.
17
+ *
18
+ * **The caller owns the table** because only the caller knows when a row's height changed, and
19
+ * recomputing it here every frame would be the cost this module exists to avoid. `offsets` holds
20
+ * `rowCount + 1` entries: the top of each row, then the bottom of the last.
21
+ */
22
+ export function visibleRangeVariable(scrollY, viewHeight, offsets, rowCount, overscan) {
23
+ if (rowCount <= 0)
24
+ return { first: 0, count: 0 };
25
+ const top = Math.max(0, scrollY);
26
+ const bottom = top + viewHeight;
27
+ /* Largest index whose top is at or before the window's top. */
28
+ let lo = 0;
29
+ let hi = rowCount;
30
+ while (lo < hi) {
31
+ const mid = (lo + hi + 1) >> 1;
32
+ if ((offsets[mid] ?? 0) <= top)
33
+ lo = mid;
34
+ else
35
+ hi = mid - 1;
36
+ }
37
+ const first = lo - overscan;
38
+ /* Smallest index whose top is at or after the window's bottom. */
39
+ let a = 0;
40
+ let b = rowCount;
41
+ while (a < b) {
42
+ const mid = (a + b) >> 1;
43
+ if ((offsets[mid] ?? 0) >= bottom)
44
+ b = mid;
45
+ else
46
+ a = mid + 1;
47
+ }
48
+ return clampWindow(first, a + overscan, rowCount);
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/ui2d",
3
- "version": "3.62.0",
3
+ "version": "4.0.0",
4
4
  "description": "The 2D layer: batched sprites, sheets, tilemaps, and a retained interface tree over them",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  ],
28
28
  "sideEffects": false,
29
29
  "peerDependencies": {
30
- "@driftengine/core": "3.62.0"
30
+ "@driftengine/core": "4.0.0"
31
31
  },
32
32
  "author": "Drift Technologies",
33
33
  "repository": {
package/src/a11y.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The interface tree in the shape assistive technology needs, and the seam that publishes it.
3
+ *
4
+ * **A seam, because whether anything can receive this is the host's business.** A browser host can
5
+ * mirror the tree into a hidden structure that a screen reader walks. A native host may have a
6
+ * platform accessibility service, or may have nothing — and `createNullA11yHost` is the honest
7
+ * answer in that case rather than an absence nobody noticed.
8
+ */
9
+ import type { UiNode } from './uiNode.ts';
10
+
11
+ export interface A11yNode {
12
+ role: string;
13
+ label: string;
14
+ x: number;
15
+ y: number;
16
+ w: number;
17
+ h: number;
18
+ focused: boolean;
19
+ }
20
+
21
+ export interface A11yHost {
22
+ publish(nodes: readonly A11yNode[], count: number): void;
23
+ }
24
+
25
+ /** A host that accepts a tree and does nothing with it. See the header. */
26
+ export function createNullA11yHost(): A11yHost {
27
+ return { publish() {} };
28
+ }
29
+
30
+ /**
31
+ * What role this node plays, derived rather than declared.
32
+ *
33
+ * Derived, because a role a caller has to set is a role most callers will not set, and an
34
+ * accessibility tree that is mostly unlabelled is worse than none: it reads as though the interface
35
+ * has been described when it has not.
36
+ */
37
+ function roleOf(node: UiNode): string {
38
+ if (node.focusable && node.children.length === 0) return 'button';
39
+ if (node.clip && (node.contentSpanX > node.rect.w || node.contentSpanY > node.rect.h)) {
40
+ return 'scrollable';
41
+ }
42
+ return 'text';
43
+ }
44
+
45
+ function contributes(node: UiNode): boolean {
46
+ return node.interactive || node.focusable || node.text.length > 0;
47
+ }
48
+
49
+ function gather(node: UiNode, out: A11yNode[], at: number): number {
50
+ if (node.hidden) return at;
51
+ let next = at;
52
+ if (contributes(node)) {
53
+ const slot = out[next] ?? { role: '', label: '', x: 0, y: 0, w: 0, h: 0, focused: false };
54
+ slot.role = roleOf(node);
55
+ slot.label = node.text;
56
+ slot.x = node.rect.x;
57
+ slot.y = node.rect.y;
58
+ slot.w = node.rect.w;
59
+ slot.h = node.rect.h;
60
+ slot.focused = false;
61
+ out[next] = slot;
62
+ next += 1;
63
+ }
64
+ for (const child of node.children) next = gather(child, out, next);
65
+ return next;
66
+ }
67
+
68
+ /** Fill `out` with every node worth describing, in tree order. Returns how many. */
69
+ export function a11yTree(root: UiNode, out: A11yNode[]): number {
70
+ const count = gather(root, out, 0);
71
+ out.length = count;
72
+ return count;
73
+ }
package/src/index.ts CHANGED
@@ -53,3 +53,57 @@ export { uiFocusNext, uiFocusOrder, uiFocusPrevious, uiHitTest } from './uiFocus
53
53
 
54
54
  export { createUiInput, resetUiInput, routeUiKey, routeUiPointer, setUiFocus } from './uiInput.ts';
55
55
  export type { UiInput } from './uiInput.ts';
56
+
57
+ /*
58
+ * The interface framework: what an editor needs from a 2D layer and a game's interface wants too.
59
+ *
60
+ * Every platform touch below is a capability the caller supplies — `TextHost` for composition and
61
+ * the clipboard, `A11yHost` for assistive technology — because this has to run under a host with
62
+ * no document object model. That is the same rule `AGENTS.md` already applies to persistence.
63
+ */
64
+ export { clipRectFor, intersectClip } from './uiClip.ts';
65
+ export { clampScroll, routeScrollWheel, scrollBy, scrollExtent } from './uiScroll.ts';
66
+ export { layerOrder } from './uiLayer.ts';
67
+ export { SLICE_STRIDE, sliceInto } from './uiSlice.ts';
68
+ export type { SliceInset } from './uiSlice.ts';
69
+ export {
70
+ createTextModel,
71
+ deleteBackward,
72
+ deleteForward,
73
+ insertText,
74
+ moveCaret,
75
+ moveToLineEdge,
76
+ selectAll,
77
+ selectedText,
78
+ selectionRange,
79
+ } from './textModel.ts';
80
+ export type { TextModel } from './textModel.ts';
81
+ export { applyComposition, createNullTextHost } from './textHost.ts';
82
+ export type { TextHost } from './textHost.ts';
83
+ export { runsFor } from './richText.ts';
84
+ export type { TextRun } from './richText.ts';
85
+ export { visibleRange, visibleRangeVariable } from './virtualList.ts';
86
+ export type { RowWindow } from './virtualList.ts';
87
+ export {
88
+ createTheme,
89
+ deriveTheme,
90
+ themeColour,
91
+ themeRgba,
92
+ themeSize,
93
+ unpackRgba,
94
+ } from './theme.ts';
95
+ export type { Theme } from './theme.ts';
96
+ export { clearDirty, createDirtyTracker, diffTree, dirtyBounds, markDirty } from './uiDirty.ts';
97
+ export type { DirtyTracker } from './uiDirty.ts';
98
+ export {
99
+ beginDrag,
100
+ capturePointer,
101
+ createPointerState,
102
+ dropTarget,
103
+ endDrag,
104
+ pointerTarget,
105
+ releasePointer,
106
+ } from './uiPointer.ts';
107
+ export type { PointerState } from './uiPointer.ts';
108
+ export { a11yTree, createNullA11yHost } from './a11y.ts';
109
+ export type { A11yHost, A11yNode } from './a11y.ts';
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Overlapping style runs, normalised into disjoint ones that cover every character once.
3
+ *
4
+ * **The normalisation is the whole job.** A syntax highlighter emits overlapping runs — a rule for
5
+ * the string, a more specific one for the escape inside it — and a renderer needs disjoint runs in
6
+ * order with no gaps. Every consumer that skips this step draws a character twice or not at all,
7
+ * and both look like a font problem.
8
+ *
9
+ * **Later wins the overlap**, because a later rule is the more specific one in every highlighter
10
+ * that emits them in that order.
11
+ */
12
+ export interface TextRun {
13
+ from: number;
14
+ to: number;
15
+ colour: number;
16
+ bold: boolean;
17
+ italic: boolean;
18
+ }
19
+
20
+ /* Reused across calls: this runs per frame for a visible document. */
21
+ const BOUNDS: number[] = [];
22
+
23
+ /**
24
+ * Fill `out` with disjoint, ordered, gap-free runs covering `[0, text.length)`.
25
+ *
26
+ * Returns how many were written. With no input runs the answer is one run covering everything,
27
+ * which is what a plain string is.
28
+ */
29
+ export function runsFor(text: string, runs: readonly TextRun[], out: TextRun[]): number {
30
+ const end = text.length;
31
+ if (end === 0) return 0;
32
+
33
+ BOUNDS.length = 0;
34
+ BOUNDS.push(0, end);
35
+ for (const run of runs) {
36
+ const from = Math.max(0, Math.min(end, run.from));
37
+ const to = Math.max(0, Math.min(end, run.to));
38
+ if (to > from) BOUNDS.push(from, to);
39
+ }
40
+ BOUNDS.sort((a, b) => a - b);
41
+
42
+ let count = 0;
43
+ let previous = -1;
44
+ for (let i = 0; i < BOUNDS.length - 1; i += 1) {
45
+ const from = BOUNDS[i] as number;
46
+ const to = BOUNDS[i + 1] as number;
47
+ if (from === to || from === previous) continue;
48
+ previous = from;
49
+
50
+ /* Last covering run wins; none covering means the default, which is what index -1 encodes. */
51
+ let chosen = -1;
52
+ for (let r = 0; r < runs.length; r += 1) {
53
+ const run = runs[r] as TextRun;
54
+ if (run.from <= from && run.to >= to) chosen = r;
55
+ }
56
+ const style = chosen === -1 ? null : (runs[chosen] as TextRun);
57
+
58
+ const slot = out[count] ?? { from: 0, to: 0, colour: 0xffffffff, bold: false, italic: false };
59
+ slot.from = from;
60
+ slot.to = to;
61
+ slot.colour = style?.colour ?? 0xffffffff;
62
+ slot.bold = style?.bold ?? false;
63
+ slot.italic = style?.italic ?? false;
64
+ out[count] = slot;
65
+ count += 1;
66
+ }
67
+ out.length = count;
68
+ return count;
69
+ }
package/src/spriteGl.ts CHANGED
@@ -129,9 +129,25 @@ export function setWebgl2SpriteTexture(
129
129
  gl.bindTexture(gl.TEXTURE_2D, texture);
130
130
  const internal = options.colorSpace === 'linear' ? gl.RGBA : gl.SRGB8_ALPHA8;
131
131
  gl.texImage2D(gl.TEXTURE_2D, 0, internal, gl.RGBA, gl.UNSIGNED_BYTE, source);
132
- const filter = options.filter === 'linear' ? gl.LINEAR : gl.NEAREST;
133
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
134
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
132
+ const linear = options.filter === 'linear';
133
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, linear ? gl.LINEAR : gl.NEAREST);
134
+ /*
135
+ * **Minification is the chain's question and magnification is `filter`'s**, which is why the two
136
+ * are set apart here. A mipmapped sheet blends *between* levels whichever filter was asked for:
137
+ * that is what stops a stroke disappearing on the way down, and `filter` still decides whether
138
+ * the texels inside a level are blended. `SurfaceTexture` splits it the same way.
139
+ */
140
+ gl.texParameteri(
141
+ gl.TEXTURE_2D,
142
+ gl.TEXTURE_MIN_FILTER,
143
+ options.mipmap === true
144
+ ? linear
145
+ ? gl.LINEAR_MIPMAP_LINEAR
146
+ : gl.NEAREST_MIPMAP_LINEAR
147
+ : linear
148
+ ? gl.LINEAR
149
+ : gl.NEAREST,
150
+ );
135
151
  /*
136
152
  * Clamped on both axes, and it is a correctness rule rather than a default. A sheet frame's
137
153
  * edge texel is adjacent to the *next* frame's, so a repeating wrap bleeds one sprite into
@@ -139,6 +155,9 @@ export function setWebgl2SpriteTexture(
139
155
  */
140
156
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
141
157
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
158
+ /* After the upload, which is the only order that works: the chain is built from level 0, and a
159
+ driver that has not been given one yet builds nothing from it. */
160
+ if (options.mipmap === true) gl.generateMipmap(gl.TEXTURE_2D);
142
161
  gl.bindTexture(gl.TEXTURE_2D, null);
143
162
  gl.activeTexture(gl.TEXTURE0);
144
163
  sprites.textures[slot] = texture;
package/src/spriteGpu.ts CHANGED
@@ -7,6 +7,16 @@ import {
7
7
  } from './shaders/generated/sprite.wgsl.ts';
8
8
  import { SPRITE_FLOATS } from './spriteBatch.ts';
9
9
  import type { SpriteBatch } from './spriteBatch.ts';
10
+ /*
11
+ * **The first runtime import this package takes from the engine, and it was measured before it was
12
+ * taken.** Everything else here is `import type`, which erases, so `ui2d` has had no runtime edge
13
+ * to `core` at all — and a value import from a barrel is how a package quietly acquires the whole
14
+ * of another one. Bundled alone, with the import and with it stubbed out: **32,801 against 31,031
15
+ * raw and 11,777 against 11,163 gzipped**, so 614 gzipped bytes, which is the blit and its shader
16
+ * and nothing else. The alternative was a second copy of the mip blit living here, and the second
17
+ * copy is the one that ends up disagreeing about the colour space.
18
+ */
19
+ import { generateMipChain, mipLevelCount, type MipPipelines } from '@driftengine/core';
10
20
  import type { SpriteImage, SpriteTextureOptions } from './spriteTexture.ts';
11
21
 
12
22
  const VERT = SPRITE_BINDINGS.SPRITE_VERT;
@@ -30,7 +40,14 @@ export interface GpuSprites {
30
40
  readonly vertexUniforms: GPUBuffer;
31
41
  readonly fragmentUniforms: GPUBuffer;
32
42
  readonly instances: GPUBuffer;
33
- readonly samplers: { readonly nearest: GPUSampler; readonly linear: GPUSampler };
43
+ readonly samplers: {
44
+ readonly nearest: GPUSampler;
45
+ readonly linear: GPUSampler;
46
+ readonly nearestMip: GPUSampler;
47
+ readonly linearMip: GPUSampler;
48
+ };
49
+ /** One mip-blit pipeline per format, built on first use. See `generateMipChain`. */
50
+ readonly mipPipelines: MipPipelines;
34
51
  readonly slots: (GpuSpriteSlot | null)[];
35
52
  readonly vertexScratch: ArrayBuffer;
36
53
  readonly vertexFloats: Float32Array;
@@ -146,7 +163,23 @@ export function createGpuSprites(
146
163
  magFilter: 'linear',
147
164
  minFilter: 'linear',
148
165
  }),
166
+ /*
167
+ * The mipmapped pair. `mipmapFilter` is the level blend and is `linear` in both, because
168
+ * that is minification; `magFilter` is what `filter` was asked about. Four samplers rather
169
+ * than two because the questions are independent, and the same split `SurfaceTexture` makes.
170
+ */
171
+ nearestMip: device.createSampler({
172
+ label: `${label}.nearestMip`,
173
+ mipmapFilter: 'linear',
174
+ }),
175
+ linearMip: device.createSampler({
176
+ label: `${label}.linearMip`,
177
+ magFilter: 'linear',
178
+ minFilter: 'linear',
179
+ mipmapFilter: 'linear',
180
+ }),
149
181
  },
182
+ mipPipelines: mipPipelineCache(device),
150
183
  slots: new Array<null>(slots).fill(null),
151
184
  vertexScratch,
152
185
  vertexFloats: new Float32Array(vertexScratch),
@@ -156,6 +189,26 @@ export function createGpuSprites(
156
189
  };
157
190
  }
158
191
 
192
+ /**
193
+ * One pipeline per format, kept on the sprite pass rather than in the renderer's cache.
194
+ *
195
+ * The renderer's `PipelineCache` is not reachable from a contributed pass, and the chain builder
196
+ * takes an interface for exactly that reason. A `Map` is the whole of what is needed: there are at
197
+ * most two formats here, and they are built once each.
198
+ */
199
+ function mipPipelineCache(device: GPUDevice): MipPipelines {
200
+ const built = new Map<string, GPURenderPipeline>();
201
+ return {
202
+ get(key: string, describe: () => GPURenderPipelineDescriptor): GPURenderPipeline {
203
+ const existing = built.get(key);
204
+ if (existing !== undefined) return existing;
205
+ const pipeline = device.createRenderPipeline(describe());
206
+ built.set(key, pipeline);
207
+ return pipeline;
208
+ },
209
+ };
210
+ }
211
+
159
212
  export function setGpuSpriteTexture(
160
213
  device: GPUDevice,
161
214
  sprites: GpuSprites,
@@ -165,10 +218,16 @@ export function setGpuSpriteTexture(
165
218
  ): void {
166
219
  const previous = sprites.slots[slot];
167
220
  if (previous !== null && previous !== undefined) previous.texture.destroy();
221
+ const format: GPUTextureFormat =
222
+ options.colorSpace === 'linear' ? 'rgba8unorm' : 'rgba8unorm-srgb';
223
+ /* One level unless a chain was asked for. `TEXTURE_USAGE` already carries RENDER_ATTACHMENT,
224
+ which is what the blit needs to draw into each level. */
225
+ const levels = options.mipmap === true ? mipLevelCount(source.width, source.height) : 1;
168
226
  const texture = device.createTexture({
169
227
  label: `ui2d.sprite.${slot}`,
170
228
  size: [source.width, source.height],
171
- format: options.colorSpace === 'linear' ? 'rgba8unorm' : 'rgba8unorm-srgb',
229
+ format,
230
+ mipLevelCount: levels,
172
231
  usage: TEXTURE_USAGE,
173
232
  });
174
233
  /*
@@ -182,7 +241,18 @@ export function setGpuSpriteTexture(
182
241
  { texture },
183
242
  [source.width, source.height],
184
243
  );
185
- const sampler = options.filter === 'linear' ? sprites.samplers.linear : sprites.samplers.nearest;
244
+ /* After the copy, which is the only order that works: every level is rendered from the one above
245
+ it, and level 0 has to hold the image before the chain can be built from it. */
246
+ generateMipChain(device, sprites.mipPipelines, texture, format, levels);
247
+ const linear = options.filter === 'linear';
248
+ const sampler =
249
+ levels > 1
250
+ ? linear
251
+ ? sprites.samplers.linearMip
252
+ : sprites.samplers.nearestMip
253
+ : linear
254
+ ? sprites.samplers.linear
255
+ : sprites.samplers.nearest;
186
256
  const bindGroup = device.createBindGroup({
187
257
  label: `ui2d.sprite.${slot}.bindGroup`,
188
258
  layout: sprites.layout,
@@ -34,9 +34,40 @@ export interface SpriteTextureOptions {
34
34
  * and saves the sharp case from a blur nobody asked for.
35
35
  */
36
36
  readonly filter?: 'nearest' | 'linear';
37
+ /**
38
+ * Whether to build a mip chain and sample it. `false` by default, which is the sheet above.
39
+ *
40
+ * **The case it exists for is type baked into an atlas.** The comment on `filter` says a sheet
41
+ * that is not pixel art "is normally drawn near its authored size, where the two filters differ
42
+ * by very little" — a glyph page is the sheet where that is not true. A consumer baking one page
43
+ * per weight at 96 px and drawing body copy at 11 is minifying **7x**, and `linear` reads four
44
+ * texels of a footprint that covers dozens: a `t` crossbar two texels tall lands on about a
45
+ * quarter of a pixel, and whether it survives depends on where the sample falls. Reported by a
46
+ * player as `Step-In Uppercut` reading `Slep-In Uppercul`, with different strokes lost per
47
+ * glyph and per position, so the line looks unevenly spaced as well as misread.
48
+ *
49
+ * Neither workaround available to a consumer is good: baking nearer the drawn size blurs the
50
+ * headings, because one atlas cannot serve an 8x range of sizes, and baking a second atlas for
51
+ * small text doubles the pages and the uploads and moves a sampling decision into the game where
52
+ * it has to be re-tuned whenever a size changes.
53
+ *
54
+ * **Off by default because the chain is wrong for pixel art**, which is what most sheets are: it
55
+ * is memory nothing samples, and at a distance it dissolves art whose whole point is the pixel.
56
+ *
57
+ * With `filter: 'linear'` this is trilinear — blended within a level and between levels. With
58
+ * `filter: 'nearest'` the levels are still blended, because that is minification and `filter` is
59
+ * about magnification; `SurfaceTexture` makes the same split and for the same reason.
60
+ *
61
+ * **Padding cells against bleed is the caller's problem**, and it has to be: a lower level mixes
62
+ * texels the atlas packer put next to each other, so a sheet whose frames touch will show its
63
+ * neighbours. How much padding depends on how far the chain is allowed to go, which is a fact
64
+ * about the sheet rather than about the sampler.
65
+ */
66
+ readonly mipmap?: boolean;
37
67
  }
38
68
 
39
69
  export const DEFAULT_SPRITE_TEXTURE_OPTIONS: SpriteTextureOptions = {
40
70
  colorSpace: 'srgb',
41
71
  filter: 'nearest',
72
+ mipmap: false,
42
73
  };