@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.
- package/NOTICE +20 -0
- package/README.md +28 -1
- package/dist/a11y.d.ts +25 -0
- package/dist/a11y.js +48 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +19 -0
- package/dist/richText.d.ts +25 -0
- package/dist/richText.js +49 -0
- package/dist/spriteGl.js +19 -3
- package/dist/spriteGpu.d.ts +5 -0
- package/dist/spriteGpu.js +63 -2
- package/dist/spriteTexture.d.ts +30 -0
- package/dist/spriteTexture.js +1 -0
- package/dist/textHost.d.ts +41 -0
- package/dist/textHost.js +44 -0
- package/dist/textModel.d.ts +45 -0
- package/dist/textModel.js +84 -0
- package/dist/theme.d.ts +30 -0
- package/dist/theme.js +36 -0
- package/dist/uiClip.d.ts +28 -0
- package/dist/uiClip.js +46 -0
- package/dist/uiDirty.d.ts +47 -0
- package/dist/uiDirty.js +116 -0
- package/dist/uiLayer.d.ts +18 -0
- package/dist/uiLayer.js +30 -0
- package/dist/uiLayout.js +29 -2
- package/dist/uiNode.d.ts +29 -0
- package/dist/uiNode.js +6 -0
- package/dist/uiPointer.d.ts +47 -0
- package/dist/uiPointer.js +50 -0
- package/dist/uiScroll.d.ts +38 -0
- package/dist/uiScroll.js +65 -0
- package/dist/uiSlice.d.ts +25 -0
- package/dist/uiSlice.js +51 -0
- package/dist/virtualList.d.ts +24 -0
- package/dist/virtualList.js +49 -0
- package/package.json +2 -2
- package/src/a11y.ts +73 -0
- package/src/index.ts +54 -0
- package/src/richText.ts +69 -0
- package/src/spriteGl.ts +22 -3
- package/src/spriteGpu.ts +73 -3
- package/src/spriteTexture.ts +31 -0
- package/src/textHost.ts +66 -0
- package/src/textModel.ts +126 -0
- package/src/theme.ts +57 -0
- package/src/uiClip.ts +69 -0
- package/src/uiDirty.ts +164 -0
- package/src/uiLayer.ts +47 -0
- package/src/uiLayout.ts +30 -2
- package/src/uiNode.ts +35 -0
- package/src/uiPointer.ts +84 -0
- package/src/uiScroll.ts +90 -0
- package/src/uiSlice.ts +90 -0
- package/src/virtualList.ts +74 -0
package/NOTICE
CHANGED
|
@@ -7,3 +7,23 @@ This product includes software developed at Drift Technologies
|
|
|
7
7
|
Licensed under the Apache License, Version 2.0. Section 4(d) of that licence
|
|
8
8
|
requires this NOTICE to be reproduced in any derivative work you distribute,
|
|
9
9
|
in the source, the documentation, or a display generated by the work.
|
|
10
|
+
|
|
11
|
+
Models run by DriftCapture
|
|
12
|
+
--------------------------
|
|
13
|
+
DriftCapture runs the models below. Their weights are not in this repository
|
|
14
|
+
and are not distributed with it: tools/capture-weights/ fetches each at the
|
|
15
|
+
revision its manifest pins, verifies it, and converts it, and a game that
|
|
16
|
+
ships one ships it under that model's own licence. Each model's definition in
|
|
17
|
+
this engine is written from its project's published code.
|
|
18
|
+
|
|
19
|
+
- Depth Anything 3 Small and Depth Anything 3 Base. Apache License 2.0.
|
|
20
|
+
Copyright (c) 2025 ByteDance Ltd. and/or its affiliates.
|
|
21
|
+
- Depth Anything V2 Small. Apache License 2.0. The University of Hong Kong
|
|
22
|
+
and TikTok; converted to the Transformers layout by The HuggingFace Team.
|
|
23
|
+
- MobileSAM. Apache License 2.0. Its image encoder is TinyViT, Copyright (c)
|
|
24
|
+
2022 Microsoft; its prompt encoder and mask decoder are Segment Anything's,
|
|
25
|
+
Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
26
|
+
- SAM 2.1 tiny. Apache License 2.0. Copyright (c) Meta Platforms, Inc. and
|
|
27
|
+
affiliates.
|
|
28
|
+
- OWLv2 base patch16. Apache License 2.0. Copyright 2023 Google AI and The
|
|
29
|
+
HuggingFace Team.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The 2D layer: quads with a texture on them, batched, in the order you drew them.
|
|
4
4
|
|
|
5
|
-
**Cost: 8.
|
|
5
|
+
**Cost: 8.7 KB gzipped on top of core.** Measured by `scripts/size-gate.test.mjs`, which fails if it
|
|
6
6
|
drifts more than 3% — the number is derived from the same floors that gate asserts, so a README
|
|
7
7
|
quoting a stale one is a red suite rather than a thing somebody notices.
|
|
8
8
|
|
|
@@ -119,6 +119,33 @@ decodes and the pass's own output transform re-encodes, so the blend happens in
|
|
|
119
119
|
`filter` defaults to `nearest`, because a sheet is usually pixel art and linear filtering is the
|
|
120
120
|
thing that makes pixel art look wrong.
|
|
121
121
|
|
|
122
|
+
### Type in an atlas wants `mipmap: true`
|
|
123
|
+
|
|
124
|
+
`mipmap` defaults to false, which is right for pixel art: a chain is memory nothing samples, and at
|
|
125
|
+
a distance it dissolves art whose whole point is the pixel. **A glyph page is the sheet where that
|
|
126
|
+
default is wrong.** Bake one page at 96 px, draw body copy at 11, and that is a 7x minification:
|
|
127
|
+
`linear` reads four texels out of a footprint covering dozens, so a `t` crossbar two texels tall
|
|
128
|
+
lands on about a quarter of a pixel and survives or not depending on where the sample falls. A
|
|
129
|
+
player reported it as `Step-In Uppercut` reading `Slep-In Uppercul`, with the same letter surviving
|
|
130
|
+
in one word and not the next, so the line looked unevenly spaced as well as misread.
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
sprites.setTexture(0, glyphPage, { filter: 'linear', mipmap: true });
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Measured on `demo/dev/glyphMip.ts`, twelve copies of one glyph at different subpixel offsets: the
|
|
137
|
+
ink varies between copies with a standard deviation of **0.0130** plain and **0.0020** mipmapped, so
|
|
138
|
+
**6.6x less** copy-to-copy disagreement, identical on both backends. `scripts/glyph-mip-check.mjs`
|
|
139
|
+
is that measurement as a gate.
|
|
140
|
+
|
|
141
|
+
With `filter: 'linear'` this is trilinear. With `filter: 'nearest'` the levels are still blended,
|
|
142
|
+
because that is minification and `filter` is about magnification.
|
|
143
|
+
|
|
144
|
+
**Pad the cells yourself.** A lower level mixes texels the packer put next to each other, so a sheet
|
|
145
|
+
whose frames touch shows its neighbours once the chain is deep enough to reach them. How much
|
|
146
|
+
padding depends on how far down the sheet is ever sampled, which is a fact about the sheet rather
|
|
147
|
+
than about the sampler — the engine cannot size it for you.
|
|
148
|
+
|
|
122
149
|
**Neither backend flips the image.** `surfaceTexture.ts` in core carries the whole argument and the
|
|
123
150
|
bug it came from: WebGL2 ignores `UNPACK_FLIP_Y_WEBGL` for an `ImageBitmap` and honours it for a
|
|
124
151
|
canvas, so a pipeline checked with one source type is half checked. `scripts/sprite-check.mjs`
|
package/dist/a11y.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
export interface A11yNode {
|
|
11
|
+
role: string;
|
|
12
|
+
label: string;
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
w: number;
|
|
16
|
+
h: number;
|
|
17
|
+
focused: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface A11yHost {
|
|
20
|
+
publish(nodes: readonly A11yNode[], count: number): void;
|
|
21
|
+
}
|
|
22
|
+
/** A host that accepts a tree and does nothing with it. See the header. */
|
|
23
|
+
export declare function createNullA11yHost(): A11yHost;
|
|
24
|
+
/** Fill `out` with every node worth describing, in tree order. Returns how many. */
|
|
25
|
+
export declare function a11yTree(root: UiNode, out: A11yNode[]): number;
|
package/dist/a11y.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** A host that accepts a tree and does nothing with it. See the header. */
|
|
2
|
+
export function createNullA11yHost() {
|
|
3
|
+
return { publish() { } };
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* What role this node plays, derived rather than declared.
|
|
7
|
+
*
|
|
8
|
+
* Derived, because a role a caller has to set is a role most callers will not set, and an
|
|
9
|
+
* accessibility tree that is mostly unlabelled is worse than none: it reads as though the interface
|
|
10
|
+
* has been described when it has not.
|
|
11
|
+
*/
|
|
12
|
+
function roleOf(node) {
|
|
13
|
+
if (node.focusable && node.children.length === 0)
|
|
14
|
+
return 'button';
|
|
15
|
+
if (node.clip && (node.contentSpanX > node.rect.w || node.contentSpanY > node.rect.h)) {
|
|
16
|
+
return 'scrollable';
|
|
17
|
+
}
|
|
18
|
+
return 'text';
|
|
19
|
+
}
|
|
20
|
+
function contributes(node) {
|
|
21
|
+
return node.interactive || node.focusable || node.text.length > 0;
|
|
22
|
+
}
|
|
23
|
+
function gather(node, out, at) {
|
|
24
|
+
if (node.hidden)
|
|
25
|
+
return at;
|
|
26
|
+
let next = at;
|
|
27
|
+
if (contributes(node)) {
|
|
28
|
+
const slot = out[next] ?? { role: '', label: '', x: 0, y: 0, w: 0, h: 0, focused: false };
|
|
29
|
+
slot.role = roleOf(node);
|
|
30
|
+
slot.label = node.text;
|
|
31
|
+
slot.x = node.rect.x;
|
|
32
|
+
slot.y = node.rect.y;
|
|
33
|
+
slot.w = node.rect.w;
|
|
34
|
+
slot.h = node.rect.h;
|
|
35
|
+
slot.focused = false;
|
|
36
|
+
out[next] = slot;
|
|
37
|
+
next += 1;
|
|
38
|
+
}
|
|
39
|
+
for (const child of node.children)
|
|
40
|
+
next = gather(child, out, next);
|
|
41
|
+
return next;
|
|
42
|
+
}
|
|
43
|
+
/** Fill `out` with every node worth describing, in tree order. Returns how many. */
|
|
44
|
+
export function a11yTree(root, out) {
|
|
45
|
+
const count = gather(root, out, 0);
|
|
46
|
+
out.length = count;
|
|
47
|
+
return count;
|
|
48
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -28,3 +28,24 @@ export type { UiContentSink } from './uiDraw.ts';
|
|
|
28
28
|
export { uiFocusNext, uiFocusOrder, uiFocusPrevious, uiHitTest } from './uiFocus.ts';
|
|
29
29
|
export { createUiInput, resetUiInput, routeUiKey, routeUiPointer, setUiFocus } from './uiInput.ts';
|
|
30
30
|
export type { UiInput } from './uiInput.ts';
|
|
31
|
+
export { clipRectFor, intersectClip } from './uiClip.ts';
|
|
32
|
+
export { clampScroll, routeScrollWheel, scrollBy, scrollExtent } from './uiScroll.ts';
|
|
33
|
+
export { layerOrder } from './uiLayer.ts';
|
|
34
|
+
export { SLICE_STRIDE, sliceInto } from './uiSlice.ts';
|
|
35
|
+
export type { SliceInset } from './uiSlice.ts';
|
|
36
|
+
export { createTextModel, deleteBackward, deleteForward, insertText, moveCaret, moveToLineEdge, selectAll, selectedText, selectionRange, } from './textModel.ts';
|
|
37
|
+
export type { TextModel } from './textModel.ts';
|
|
38
|
+
export { applyComposition, createNullTextHost } from './textHost.ts';
|
|
39
|
+
export type { TextHost } from './textHost.ts';
|
|
40
|
+
export { runsFor } from './richText.ts';
|
|
41
|
+
export type { TextRun } from './richText.ts';
|
|
42
|
+
export { visibleRange, visibleRangeVariable } from './virtualList.ts';
|
|
43
|
+
export type { RowWindow } from './virtualList.ts';
|
|
44
|
+
export { createTheme, deriveTheme, themeColour, themeRgba, themeSize, unpackRgba, } from './theme.ts';
|
|
45
|
+
export type { Theme } from './theme.ts';
|
|
46
|
+
export { clearDirty, createDirtyTracker, diffTree, dirtyBounds, markDirty } from './uiDirty.ts';
|
|
47
|
+
export type { DirtyTracker } from './uiDirty.ts';
|
|
48
|
+
export { beginDrag, capturePointer, createPointerState, dropTarget, endDrag, pointerTarget, releasePointer, } from './uiPointer.ts';
|
|
49
|
+
export type { PointerState } from './uiPointer.ts';
|
|
50
|
+
export { a11yTree, createNullA11yHost } from './a11y.ts';
|
|
51
|
+
export type { A11yHost, A11yNode } from './a11y.ts';
|
package/dist/index.js
CHANGED
|
@@ -19,3 +19,22 @@ export { layoutUiTree } from './uiLayout.js';
|
|
|
19
19
|
export { drawUiTree } from './uiDraw.js';
|
|
20
20
|
export { uiFocusNext, uiFocusOrder, uiFocusPrevious, uiHitTest } from './uiFocus.js';
|
|
21
21
|
export { createUiInput, resetUiInput, routeUiKey, routeUiPointer, setUiFocus } from './uiInput.js';
|
|
22
|
+
/*
|
|
23
|
+
* The interface framework: what an editor needs from a 2D layer and a game's interface wants too.
|
|
24
|
+
*
|
|
25
|
+
* Every platform touch below is a capability the caller supplies — `TextHost` for composition and
|
|
26
|
+
* the clipboard, `A11yHost` for assistive technology — because this has to run under a host with
|
|
27
|
+
* no document object model. That is the same rule `AGENTS.md` already applies to persistence.
|
|
28
|
+
*/
|
|
29
|
+
export { clipRectFor, intersectClip } from './uiClip.js';
|
|
30
|
+
export { clampScroll, routeScrollWheel, scrollBy, scrollExtent } from './uiScroll.js';
|
|
31
|
+
export { layerOrder } from './uiLayer.js';
|
|
32
|
+
export { SLICE_STRIDE, sliceInto } from './uiSlice.js';
|
|
33
|
+
export { createTextModel, deleteBackward, deleteForward, insertText, moveCaret, moveToLineEdge, selectAll, selectedText, selectionRange, } from './textModel.js';
|
|
34
|
+
export { applyComposition, createNullTextHost } from './textHost.js';
|
|
35
|
+
export { runsFor } from './richText.js';
|
|
36
|
+
export { visibleRange, visibleRangeVariable } from './virtualList.js';
|
|
37
|
+
export { createTheme, deriveTheme, themeColour, themeRgba, themeSize, unpackRgba, } from './theme.js';
|
|
38
|
+
export { clearDirty, createDirtyTracker, diffTree, dirtyBounds, markDirty } from './uiDirty.js';
|
|
39
|
+
export { beginDrag, capturePointer, createPointerState, dropTarget, endDrag, pointerTarget, releasePointer, } from './uiPointer.js';
|
|
40
|
+
export { a11yTree, createNullA11yHost } from './a11y.js';
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
* Fill `out` with disjoint, ordered, gap-free runs covering `[0, text.length)`.
|
|
21
|
+
*
|
|
22
|
+
* Returns how many were written. With no input runs the answer is one run covering everything,
|
|
23
|
+
* which is what a plain string is.
|
|
24
|
+
*/
|
|
25
|
+
export declare function runsFor(text: string, runs: readonly TextRun[], out: TextRun[]): number;
|
package/dist/richText.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/* Reused across calls: this runs per frame for a visible document. */
|
|
2
|
+
const BOUNDS = [];
|
|
3
|
+
/**
|
|
4
|
+
* Fill `out` with disjoint, ordered, gap-free runs covering `[0, text.length)`.
|
|
5
|
+
*
|
|
6
|
+
* Returns how many were written. With no input runs the answer is one run covering everything,
|
|
7
|
+
* which is what a plain string is.
|
|
8
|
+
*/
|
|
9
|
+
export function runsFor(text, runs, out) {
|
|
10
|
+
const end = text.length;
|
|
11
|
+
if (end === 0)
|
|
12
|
+
return 0;
|
|
13
|
+
BOUNDS.length = 0;
|
|
14
|
+
BOUNDS.push(0, end);
|
|
15
|
+
for (const run of runs) {
|
|
16
|
+
const from = Math.max(0, Math.min(end, run.from));
|
|
17
|
+
const to = Math.max(0, Math.min(end, run.to));
|
|
18
|
+
if (to > from)
|
|
19
|
+
BOUNDS.push(from, to);
|
|
20
|
+
}
|
|
21
|
+
BOUNDS.sort((a, b) => a - b);
|
|
22
|
+
let count = 0;
|
|
23
|
+
let previous = -1;
|
|
24
|
+
for (let i = 0; i < BOUNDS.length - 1; i += 1) {
|
|
25
|
+
const from = BOUNDS[i];
|
|
26
|
+
const to = BOUNDS[i + 1];
|
|
27
|
+
if (from === to || from === previous)
|
|
28
|
+
continue;
|
|
29
|
+
previous = from;
|
|
30
|
+
/* Last covering run wins; none covering means the default, which is what index -1 encodes. */
|
|
31
|
+
let chosen = -1;
|
|
32
|
+
for (let r = 0; r < runs.length; r += 1) {
|
|
33
|
+
const run = runs[r];
|
|
34
|
+
if (run.from <= from && run.to >= to)
|
|
35
|
+
chosen = r;
|
|
36
|
+
}
|
|
37
|
+
const style = chosen === -1 ? null : runs[chosen];
|
|
38
|
+
const slot = out[count] ?? { from: 0, to: 0, colour: 0xffffffff, bold: false, italic: false };
|
|
39
|
+
slot.from = from;
|
|
40
|
+
slot.to = to;
|
|
41
|
+
slot.colour = style?.colour ?? 0xffffffff;
|
|
42
|
+
slot.bold = style?.bold ?? false;
|
|
43
|
+
slot.italic = style?.italic ?? false;
|
|
44
|
+
out[count] = slot;
|
|
45
|
+
count += 1;
|
|
46
|
+
}
|
|
47
|
+
out.length = count;
|
|
48
|
+
return count;
|
|
49
|
+
}
|
package/dist/spriteGl.js
CHANGED
|
@@ -97,9 +97,21 @@ export function setWebgl2SpriteTexture(gl, sprites, slot, source, options) {
|
|
|
97
97
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
98
98
|
const internal = options.colorSpace === 'linear' ? gl.RGBA : gl.SRGB8_ALPHA8;
|
|
99
99
|
gl.texImage2D(gl.TEXTURE_2D, 0, internal, gl.RGBA, gl.UNSIGNED_BYTE, source);
|
|
100
|
-
const
|
|
101
|
-
gl.texParameteri(gl.TEXTURE_2D, gl.
|
|
102
|
-
|
|
100
|
+
const linear = options.filter === 'linear';
|
|
101
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, linear ? gl.LINEAR : gl.NEAREST);
|
|
102
|
+
/*
|
|
103
|
+
* **Minification is the chain's question and magnification is `filter`'s**, which is why the two
|
|
104
|
+
* are set apart here. A mipmapped sheet blends *between* levels whichever filter was asked for:
|
|
105
|
+
* that is what stops a stroke disappearing on the way down, and `filter` still decides whether
|
|
106
|
+
* the texels inside a level are blended. `SurfaceTexture` splits it the same way.
|
|
107
|
+
*/
|
|
108
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, options.mipmap === true
|
|
109
|
+
? linear
|
|
110
|
+
? gl.LINEAR_MIPMAP_LINEAR
|
|
111
|
+
: gl.NEAREST_MIPMAP_LINEAR
|
|
112
|
+
: linear
|
|
113
|
+
? gl.LINEAR
|
|
114
|
+
: gl.NEAREST);
|
|
103
115
|
/*
|
|
104
116
|
* Clamped on both axes, and it is a correctness rule rather than a default. A sheet frame's
|
|
105
117
|
* edge texel is adjacent to the *next* frame's, so a repeating wrap bleeds one sprite into
|
|
@@ -107,6 +119,10 @@ export function setWebgl2SpriteTexture(gl, sprites, slot, source, options) {
|
|
|
107
119
|
*/
|
|
108
120
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
109
121
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
122
|
+
/* After the upload, which is the only order that works: the chain is built from level 0, and a
|
|
123
|
+
driver that has not been given one yet builds nothing from it. */
|
|
124
|
+
if (options.mipmap === true)
|
|
125
|
+
gl.generateMipmap(gl.TEXTURE_2D);
|
|
110
126
|
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
111
127
|
gl.activeTexture(gl.TEXTURE0);
|
|
112
128
|
sprites.textures[slot] = texture;
|
package/dist/spriteGpu.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** The WebGPU half of the sprite pass: one pipeline, one instance buffer, a bind group per slot. */
|
|
2
2
|
import type { SpriteBatch } from './spriteBatch.ts';
|
|
3
|
+
import { type MipPipelines } from '@driftengine/core';
|
|
3
4
|
import type { SpriteImage, SpriteTextureOptions } from './spriteTexture.ts';
|
|
4
5
|
export interface GpuSpriteSlot {
|
|
5
6
|
readonly texture: GPUTexture;
|
|
@@ -14,7 +15,11 @@ export interface GpuSprites {
|
|
|
14
15
|
readonly samplers: {
|
|
15
16
|
readonly nearest: GPUSampler;
|
|
16
17
|
readonly linear: GPUSampler;
|
|
18
|
+
readonly nearestMip: GPUSampler;
|
|
19
|
+
readonly linearMip: GPUSampler;
|
|
17
20
|
};
|
|
21
|
+
/** One mip-blit pipeline per format, built on first use. See `generateMipChain`. */
|
|
22
|
+
readonly mipPipelines: MipPipelines;
|
|
18
23
|
readonly slots: (GpuSpriteSlot | null)[];
|
|
19
24
|
readonly vertexScratch: ArrayBuffer;
|
|
20
25
|
readonly vertexFloats: Float32Array;
|
package/dist/spriteGpu.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
/** The WebGPU half of the sprite pass: one pipeline, one instance buffer, a bind group per slot. */
|
|
2
2
|
import { SPRITE_BINDINGS, SPRITE_FRAG_WGSL, SPRITE_VERT_WGSL, } from './shaders/generated/sprite.wgsl.js';
|
|
3
3
|
import { SPRITE_FLOATS } from './spriteBatch.js';
|
|
4
|
+
/*
|
|
5
|
+
* **The first runtime import this package takes from the engine, and it was measured before it was
|
|
6
|
+
* taken.** Everything else here is `import type`, which erases, so `ui2d` has had no runtime edge
|
|
7
|
+
* to `core` at all — and a value import from a barrel is how a package quietly acquires the whole
|
|
8
|
+
* of another one. Bundled alone, with the import and with it stubbed out: **32,801 against 31,031
|
|
9
|
+
* raw and 11,777 against 11,163 gzipped**, so 614 gzipped bytes, which is the blit and its shader
|
|
10
|
+
* and nothing else. The alternative was a second copy of the mip blit living here, and the second
|
|
11
|
+
* copy is the one that ends up disagreeing about the colour space.
|
|
12
|
+
*/
|
|
13
|
+
import { generateMipChain, mipLevelCount } from '@driftengine/core';
|
|
4
14
|
const VERT = SPRITE_BINDINGS.SPRITE_VERT;
|
|
5
15
|
const FRAG = SPRITE_BINDINGS.SPRITE_FRAG;
|
|
6
16
|
const STRIDE = SPRITE_FLOATS * 4;
|
|
@@ -104,7 +114,23 @@ export function createGpuSprites(device, format, depthFormat, samples, capacity,
|
|
|
104
114
|
magFilter: 'linear',
|
|
105
115
|
minFilter: 'linear',
|
|
106
116
|
}),
|
|
117
|
+
/*
|
|
118
|
+
* The mipmapped pair. `mipmapFilter` is the level blend and is `linear` in both, because
|
|
119
|
+
* that is minification; `magFilter` is what `filter` was asked about. Four samplers rather
|
|
120
|
+
* than two because the questions are independent, and the same split `SurfaceTexture` makes.
|
|
121
|
+
*/
|
|
122
|
+
nearestMip: device.createSampler({
|
|
123
|
+
label: `${label}.nearestMip`,
|
|
124
|
+
mipmapFilter: 'linear',
|
|
125
|
+
}),
|
|
126
|
+
linearMip: device.createSampler({
|
|
127
|
+
label: `${label}.linearMip`,
|
|
128
|
+
magFilter: 'linear',
|
|
129
|
+
minFilter: 'linear',
|
|
130
|
+
mipmapFilter: 'linear',
|
|
131
|
+
}),
|
|
107
132
|
},
|
|
133
|
+
mipPipelines: mipPipelineCache(device),
|
|
108
134
|
slots: new Array(slots).fill(null),
|
|
109
135
|
vertexScratch,
|
|
110
136
|
vertexFloats: new Float32Array(vertexScratch),
|
|
@@ -113,14 +139,39 @@ export function createGpuSprites(device, format, depthFormat, samples, capacity,
|
|
|
113
139
|
fragmentInts: new Int32Array(fragmentScratch),
|
|
114
140
|
};
|
|
115
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* One pipeline per format, kept on the sprite pass rather than in the renderer's cache.
|
|
144
|
+
*
|
|
145
|
+
* The renderer's `PipelineCache` is not reachable from a contributed pass, and the chain builder
|
|
146
|
+
* takes an interface for exactly that reason. A `Map` is the whole of what is needed: there are at
|
|
147
|
+
* most two formats here, and they are built once each.
|
|
148
|
+
*/
|
|
149
|
+
function mipPipelineCache(device) {
|
|
150
|
+
const built = new Map();
|
|
151
|
+
return {
|
|
152
|
+
get(key, describe) {
|
|
153
|
+
const existing = built.get(key);
|
|
154
|
+
if (existing !== undefined)
|
|
155
|
+
return existing;
|
|
156
|
+
const pipeline = device.createRenderPipeline(describe());
|
|
157
|
+
built.set(key, pipeline);
|
|
158
|
+
return pipeline;
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
116
162
|
export function setGpuSpriteTexture(device, sprites, slot, source, options) {
|
|
117
163
|
const previous = sprites.slots[slot];
|
|
118
164
|
if (previous !== null && previous !== undefined)
|
|
119
165
|
previous.texture.destroy();
|
|
166
|
+
const format = options.colorSpace === 'linear' ? 'rgba8unorm' : 'rgba8unorm-srgb';
|
|
167
|
+
/* One level unless a chain was asked for. `TEXTURE_USAGE` already carries RENDER_ATTACHMENT,
|
|
168
|
+
which is what the blit needs to draw into each level. */
|
|
169
|
+
const levels = options.mipmap === true ? mipLevelCount(source.width, source.height) : 1;
|
|
120
170
|
const texture = device.createTexture({
|
|
121
171
|
label: `ui2d.sprite.${slot}`,
|
|
122
172
|
size: [source.width, source.height],
|
|
123
|
-
format
|
|
173
|
+
format,
|
|
174
|
+
mipLevelCount: levels,
|
|
124
175
|
usage: TEXTURE_USAGE,
|
|
125
176
|
});
|
|
126
177
|
/*
|
|
@@ -130,7 +181,17 @@ export function setGpuSpriteTexture(device, sprites, slot, source, options) {
|
|
|
130
181
|
* the source type most callers use.
|
|
131
182
|
*/
|
|
132
183
|
device.queue.copyExternalImageToTexture({ source: source, flipY: false }, { texture }, [source.width, source.height]);
|
|
133
|
-
|
|
184
|
+
/* After the copy, which is the only order that works: every level is rendered from the one above
|
|
185
|
+
it, and level 0 has to hold the image before the chain can be built from it. */
|
|
186
|
+
generateMipChain(device, sprites.mipPipelines, texture, format, levels);
|
|
187
|
+
const linear = options.filter === 'linear';
|
|
188
|
+
const sampler = levels > 1
|
|
189
|
+
? linear
|
|
190
|
+
? sprites.samplers.linearMip
|
|
191
|
+
: sprites.samplers.nearestMip
|
|
192
|
+
: linear
|
|
193
|
+
? sprites.samplers.linear
|
|
194
|
+
: sprites.samplers.nearest;
|
|
134
195
|
const bindGroup = device.createBindGroup({
|
|
135
196
|
label: `ui2d.sprite.${slot}.bindGroup`,
|
|
136
197
|
layout: sprites.layout,
|
package/dist/spriteTexture.d.ts
CHANGED
|
@@ -32,5 +32,35 @@ export interface SpriteTextureOptions {
|
|
|
32
32
|
* and saves the sharp case from a blur nobody asked for.
|
|
33
33
|
*/
|
|
34
34
|
readonly filter?: 'nearest' | 'linear';
|
|
35
|
+
/**
|
|
36
|
+
* Whether to build a mip chain and sample it. `false` by default, which is the sheet above.
|
|
37
|
+
*
|
|
38
|
+
* **The case it exists for is type baked into an atlas.** The comment on `filter` says a sheet
|
|
39
|
+
* that is not pixel art "is normally drawn near its authored size, where the two filters differ
|
|
40
|
+
* by very little" — a glyph page is the sheet where that is not true. A consumer baking one page
|
|
41
|
+
* per weight at 96 px and drawing body copy at 11 is minifying **7x**, and `linear` reads four
|
|
42
|
+
* texels of a footprint that covers dozens: a `t` crossbar two texels tall lands on about a
|
|
43
|
+
* quarter of a pixel, and whether it survives depends on where the sample falls. Reported by a
|
|
44
|
+
* player as `Step-In Uppercut` reading `Slep-In Uppercul`, with different strokes lost per
|
|
45
|
+
* glyph and per position, so the line looks unevenly spaced as well as misread.
|
|
46
|
+
*
|
|
47
|
+
* Neither workaround available to a consumer is good: baking nearer the drawn size blurs the
|
|
48
|
+
* headings, because one atlas cannot serve an 8x range of sizes, and baking a second atlas for
|
|
49
|
+
* small text doubles the pages and the uploads and moves a sampling decision into the game where
|
|
50
|
+
* it has to be re-tuned whenever a size changes.
|
|
51
|
+
*
|
|
52
|
+
* **Off by default because the chain is wrong for pixel art**, which is what most sheets are: it
|
|
53
|
+
* is memory nothing samples, and at a distance it dissolves art whose whole point is the pixel.
|
|
54
|
+
*
|
|
55
|
+
* With `filter: 'linear'` this is trilinear — blended within a level and between levels. With
|
|
56
|
+
* `filter: 'nearest'` the levels are still blended, because that is minification and `filter` is
|
|
57
|
+
* about magnification; `SurfaceTexture` makes the same split and for the same reason.
|
|
58
|
+
*
|
|
59
|
+
* **Padding cells against bleed is the caller's problem**, and it has to be: a lower level mixes
|
|
60
|
+
* texels the atlas packer put next to each other, so a sheet whose frames touch will show its
|
|
61
|
+
* neighbours. How much padding depends on how far the chain is allowed to go, which is a fact
|
|
62
|
+
* about the sheet rather than about the sampler.
|
|
63
|
+
*/
|
|
64
|
+
readonly mipmap?: boolean;
|
|
35
65
|
}
|
|
36
66
|
export declare const DEFAULT_SPRITE_TEXTURE_OPTIONS: SpriteTextureOptions;
|
package/dist/spriteTexture.js
CHANGED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The platform services text entry needs, taken as a parameter.
|
|
3
|
+
*
|
|
4
|
+
* **Composition — the multi-keystroke entry every language that is not typed one character at a
|
|
5
|
+
* time depends on — is a platform service.** The browser has one, a native host has a different
|
|
6
|
+
* one, and a test has none. `AGENTS.md` requires a capability be taken as a parameter with a
|
|
7
|
+
* browser implementation shipped as the default; this is that rule applied to text, and it is why
|
|
8
|
+
* this package can run under a host with no document object model at all.
|
|
9
|
+
*/
|
|
10
|
+
import type { TextModel } from './textModel.ts';
|
|
11
|
+
export interface TextHost {
|
|
12
|
+
/** Tell the host a field is focused at this rectangle, so it can place a composition window. */
|
|
13
|
+
focusField(x: number, y: number, w: number, h: number): void;
|
|
14
|
+
/** Tell the host nothing is focused. */
|
|
15
|
+
blurField(): void;
|
|
16
|
+
/** Read the clipboard, if the host has one. Asynchronous everywhere one exists. */
|
|
17
|
+
readClipboard(): Promise<string>;
|
|
18
|
+
/** Write the clipboard, if the host has one. */
|
|
19
|
+
writeClipboard(text: string): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A host that focuses nothing and whose clipboard is empty.
|
|
23
|
+
*
|
|
24
|
+
* What a test uses, and what a headless build uses. **An empty clipboard rather than a missing
|
|
25
|
+
* one**, so a caller never has to branch on whether it has a host.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createNullTextHost(): TextHost;
|
|
28
|
+
/**
|
|
29
|
+
* Fold one composition update into the model.
|
|
30
|
+
*
|
|
31
|
+
* An update replaces whatever the previous update of this composition put there, rather than the
|
|
32
|
+
* whole field — which is what makes a five-keystroke entry produce one word instead of five.
|
|
33
|
+
*
|
|
34
|
+
* **`done` clears the range, and that is the step everyone forgets.** A finished composition that
|
|
35
|
+
* leaves its range set means the *next* keystroke overwrites the text that was just committed,
|
|
36
|
+
* which presents as characters vanishing one word behind the caret.
|
|
37
|
+
*/
|
|
38
|
+
export declare function applyComposition(model: TextModel, state: {
|
|
39
|
+
text: string;
|
|
40
|
+
done: boolean;
|
|
41
|
+
}): void;
|
package/dist/textHost.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { selectionRange } from './textModel.js';
|
|
2
|
+
/**
|
|
3
|
+
* A host that focuses nothing and whose clipboard is empty.
|
|
4
|
+
*
|
|
5
|
+
* What a test uses, and what a headless build uses. **An empty clipboard rather than a missing
|
|
6
|
+
* one**, so a caller never has to branch on whether it has a host.
|
|
7
|
+
*/
|
|
8
|
+
export function createNullTextHost() {
|
|
9
|
+
return {
|
|
10
|
+
focusField() { },
|
|
11
|
+
blurField() { },
|
|
12
|
+
readClipboard: async () => '',
|
|
13
|
+
writeClipboard: async () => { },
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Fold one composition update into the model.
|
|
18
|
+
*
|
|
19
|
+
* An update replaces whatever the previous update of this composition put there, rather than the
|
|
20
|
+
* whole field — which is what makes a five-keystroke entry produce one word instead of five.
|
|
21
|
+
*
|
|
22
|
+
* **`done` clears the range, and that is the step everyone forgets.** A finished composition that
|
|
23
|
+
* leaves its range set means the *next* keystroke overwrites the text that was just committed,
|
|
24
|
+
* which presents as characters vanishing one word behind the caret.
|
|
25
|
+
*/
|
|
26
|
+
export function applyComposition(model, state) {
|
|
27
|
+
if (model.composeFrom === -1) {
|
|
28
|
+
const { from, to } = selectionRange(model);
|
|
29
|
+
model.text = model.text.slice(0, from) + state.text + model.text.slice(to);
|
|
30
|
+
model.composeFrom = from;
|
|
31
|
+
model.composeTo = from + state.text.length;
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
model.text =
|
|
35
|
+
model.text.slice(0, model.composeFrom) + state.text + model.text.slice(model.composeTo);
|
|
36
|
+
model.composeTo = model.composeFrom + state.text.length;
|
|
37
|
+
}
|
|
38
|
+
model.caret = model.composeTo;
|
|
39
|
+
model.anchor = model.caret;
|
|
40
|
+
if (state.done) {
|
|
41
|
+
model.composeFrom = -1;
|
|
42
|
+
model.composeTo = -1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A caret, a selection, and the operations that move them.
|
|
3
|
+
*
|
|
4
|
+
* **Pure, and it is pure on purpose.** Every editing defect anybody has met lives here rather than
|
|
5
|
+
* in the platform: a caret inside a character, a right-to-left selection that deletes the wrong
|
|
6
|
+
* end, a backspace that removes half an emoji. Pure, each of those is a test that runs in under a
|
|
7
|
+
* millisecond. Wired to a host, none of them is.
|
|
8
|
+
*
|
|
9
|
+
* **Offsets are into the string and are always on a code-point boundary.** JavaScript strings are
|
|
10
|
+
* UTF-16, so an astral character is two units and a caret between them is a caret inside a
|
|
11
|
+
* character — which renders as a replacement glyph and deletes as a lone surrogate. Every move and
|
|
12
|
+
* every delete steps over the pair.
|
|
13
|
+
*
|
|
14
|
+
* `anchor` is where a selection started and `caret` is where it is now, so `anchor > caret` is an
|
|
15
|
+
* ordinary right-to-left selection rather than an error. `selectionRange` is the only thing that
|
|
16
|
+
* orders them and everything else goes through it.
|
|
17
|
+
*/
|
|
18
|
+
export interface TextModel {
|
|
19
|
+
text: string;
|
|
20
|
+
/** Where the caret is, as a UTF-16 offset on a code-point boundary. */
|
|
21
|
+
caret: number;
|
|
22
|
+
/** Where the current selection began. Equal to `caret` when nothing is selected. */
|
|
23
|
+
anchor: number;
|
|
24
|
+
/**
|
|
25
|
+
* The range an in-progress composition occupies, or -1 when none is in progress.
|
|
26
|
+
*
|
|
27
|
+
* Written only by `applyComposition` in `textHost.ts`. It lives on the model rather than in the
|
|
28
|
+
* host because the range is a property of the text, and a host that crashed mid-composition
|
|
29
|
+
* would otherwise leave the field permanently convinced it was still composing.
|
|
30
|
+
*/
|
|
31
|
+
composeFrom: number;
|
|
32
|
+
composeTo: number;
|
|
33
|
+
}
|
|
34
|
+
export declare function createTextModel(text?: string): TextModel;
|
|
35
|
+
export declare function selectionRange(model: TextModel): {
|
|
36
|
+
from: number;
|
|
37
|
+
to: number;
|
|
38
|
+
};
|
|
39
|
+
export declare function selectedText(model: TextModel): string;
|
|
40
|
+
export declare function insertText(model: TextModel, s: string): void;
|
|
41
|
+
export declare function deleteBackward(model: TextModel): void;
|
|
42
|
+
export declare function deleteForward(model: TextModel): void;
|
|
43
|
+
export declare function moveCaret(model: TextModel, delta: number, select: boolean): void;
|
|
44
|
+
export declare function moveToLineEdge(model: TextModel, forward: boolean, select: boolean): void;
|
|
45
|
+
export declare function selectAll(model: TextModel): void;
|