@solidrt/2d 0.0.67 → 0.0.68

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.
@@ -0,0 +1,77 @@
1
+ import { createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
2
+ import type { VoidComponent } from "@solidrt/core"
3
+ import { setGroupTransition } from "../layer.ts"
4
+ import type { SpritePointerEvent, SpriteTapEvent, SpriteTransition, SpriteWheelEvent, TransitionEndEvent } from "../layer.ts"
5
+ import { addText, destroyText, setText } from "../text.ts"
6
+ import type { AddTextOptions, TextRun } from "../text.ts"
7
+ import { GroupContext, LayerContext } from "./context.ts"
8
+
9
+ export type Text2dProps = Omit<AddTextOptions, "parent"> & {
10
+ /** Bubbled from a hit glyph sprite to the run's group (see
11
+ * SpritePointerProps); a run never receives enter/leave. */
12
+ onPointerDown?: (event: SpritePointerEvent) => void
13
+ onPointerMove?: (event: SpritePointerEvent) => void
14
+ onPointerUp?: (event: SpritePointerEvent) => void
15
+ onPointerCancel?: (event: SpritePointerEvent) => void
16
+ onWheel?: (event: SpriteWheelEvent) => void
17
+ onTap?: (event: SpriteTapEvent) => void
18
+ /** How pose-prop changes animate (the run's group; see
19
+ * setGroupTransition). The mount pose snaps. */
20
+ transition?: SpriteTransition | string | null
21
+ onTransitionEnd?: (event: TransitionEndEvent) => void
22
+ ref?: (run: TextRun) => void
23
+ }
24
+
25
+ /**
26
+ * A text run in the enclosing `<SpriteLayer>` (under the enclosing
27
+ * `<Group>` when there is one): `text` drawn from `font`'s atlas as glyph
28
+ * sprites, at `x`/`y` in the group's frame, the style props re-laying it
29
+ * out when they change. The layer must declare `font.atlas`. Renders
30
+ * nothing itself; `Camera2d`, picking and `orderBy` see the glyphs as
31
+ * sprites.
32
+ */
33
+ export let Text2d: VoidComponent<Text2dProps> = props => {
34
+ let layer = useContext(LayerContext)
35
+ let parent = useContext(GroupContext)
36
+ let run = untrack(() =>
37
+ addText(layer, {
38
+ font: props.font,
39
+ text: props.text,
40
+ x: props.x,
41
+ y: props.y,
42
+ rotation: props.rotation,
43
+ visible: props.visible,
44
+ parent,
45
+ }),
46
+ )
47
+ createEffect(
48
+ () => [props.x, props.y, props.rotation, props.visible] as const,
49
+ ([x, y, rotation, visible]) => setText(run, { x, y, rotation, visible: visible !== false }),
50
+ )
51
+ createEffect(
52
+ () =>
53
+ [props.text, props.fontSize, props.lineHeight, props.letterSpacing, props.maxWidth, props.align, props.anchor, props.anchorY, props.tint, props.outline, props.renderOrder] as const,
54
+ ([text, fontSize, lineHeight, letterSpacing, maxWidth, align, anchor, anchorY, tint, outline, renderOrder]) =>
55
+ setText(run, { text, fontSize, lineHeight, letterSpacing, maxWidth, align, anchor, anchorY, tint, outline, renderOrder }),
56
+ )
57
+ // After the pose effect, so the mount pose snaps before writes animate.
58
+ createEffect(
59
+ () => props.transition,
60
+ transition => setGroupTransition(run.group, transition ?? null),
61
+ )
62
+ createEffect(
63
+ () => [props.onPointerDown, props.onPointerMove, props.onPointerUp, props.onPointerCancel, props.onWheel, props.onTap, props.onTransitionEnd] as const,
64
+ ([down, move, up, cancel, wheel, tap, end]) => {
65
+ run.group.onPointerDown = down
66
+ run.group.onPointerMove = move
67
+ run.group.onPointerUp = up
68
+ run.group.onPointerCancel = cancel
69
+ run.group.onWheel = wheel
70
+ run.group.onTap = tap
71
+ run.group.onTransitionEnd = end
72
+ },
73
+ )
74
+ untrack(() => props.ref)?.(run)
75
+ onCleanup(() => destroyText(run))
76
+ return null
77
+ }
@@ -1,6 +1,7 @@
1
1
  import { createEffect, createSignal, displayScale, For, getBoundingBoxViewport, onLayout, untrack } from "@solidrt/core"
2
- import type { TextureId, VoidComponent } from "@solidrt/core"
3
- import type { FilterMode } from "@solidrt/core/gpu"
2
+ import type { VoidComponent } from "@solidrt/core"
3
+ import type { BlendMode, FilterMode } from "@solidrt/core/gpu"
4
+ import type { Atlas } from "../atlas.ts"
4
5
  import type { CameraUpdate } from "../camera.ts"
5
6
  import type { Frame } from "../frames.ts"
6
7
  import { tileWorldScale } from "../oversample-math.ts"
@@ -24,8 +25,9 @@ export type TileLayerProps = {
24
25
  rows: number
25
26
  tileW: number
26
27
  tileH: number
27
- /** The atlas texture every tile samples (create with createAtlas). */
28
- atlas: TextureId
28
+ /** The tilesets the cells draw from (createAtlas records), bound as one
29
+ * bake; every frame set names one of them. Fixed at mount. */
30
+ atlases: Atlas[]
29
31
  /** The frames table `setTiles`' index cells name (a tileset, `grid()`'s
30
32
  * array); see TileLayerOptions. Fixed at creation. */
31
33
  frames?: Frame[]
@@ -33,6 +35,9 @@ export type TileLayerProps = {
33
35
  * have no chunk and render nothing, so a full-bleed ground color belongs
34
36
  * on the container behind the layer). */
35
37
  chunkClearColor?: [number, number, number, number]
38
+ /** How the cells blend into their chunk bakes (see
39
+ * TileLayerOptions.blend); default "alpha". Fixed at mount. */
40
+ blend?: BlendMode
36
41
  /** Sampler filter for the baked chunk textures at composite time; default
37
42
  * "linear" (hard pixels belong to the atlas sampler, see TileLayerOptions). */
38
43
  filter?: FilterMode
@@ -81,8 +86,9 @@ export type TileLayerProps = {
81
86
  */
82
87
  export let TileLayer: VoidComponent<TileLayerProps> = props => {
83
88
  let layer = untrack(() =>
84
- createTileLayer(props.cols, props.rows, props.tileW, props.tileH, props.atlas, {
89
+ createTileLayer(props.cols, props.rows, props.tileW, props.tileH, props.atlases, {
85
90
  chunkClearColor: props.chunkClearColor,
91
+ blend: props.blend,
86
92
  filter: props.filter,
87
93
  chunkTiles: props.chunkTiles,
88
94
  tint: props.tint,
package/src/dispatch.ts CHANGED
@@ -42,7 +42,8 @@ const TAP_REPEAT_SLOP = 20
42
42
  export type DispatchDeps = {
43
43
  size: () => [number, number]
44
44
  camera: () => CameraUpdate
45
- pick: (x: number, y: number) => Sprite[]
45
+ /** The layer's pick, `zoom` the camera zoom its floors are measured at. */
46
+ pick: (x: number, y: number, zoom: number) => Sprite[]
46
47
  /** The view: the walk's root and the listeners' currentTarget. */
47
48
  root: ViewHandle
48
49
  /** The root's listeners, in registration order. */
@@ -151,7 +152,7 @@ export function spriteDispatch(deps: DispatchDeps): (layout: (() => { width: num
151
152
  }
152
153
  return event
153
154
  }
154
- let topmost = (x: number, y: number): Sprite | null => deps.pick(x, y)[0] ?? null
155
+ let topmost = (x: number, y: number): Sprite | null => deps.pick(x, y, deps.camera().zoom ?? 1)[0] ?? null
155
156
  // Enter/leave go to one sprite directly, outside the walk: the event
156
157
  // in its sprite view.
157
158
  let hoverEvent = (sprite: Sprite, x: number, y: number, e: ElementPointerEvent): SpritePointerEvent =>
package/src/extrude.ts ADDED
@@ -0,0 +1,156 @@
1
+ // Atlas extrusion, pure: repack a sheet so every cell sits in a gutter of
2
+ // its own replicated edge pixels - TexturePacker's "Extrude", Unity's
3
+ // "Extrude Edges" import setting - the answer to mip-chain bleed. The
4
+ // layer shaders clamp every sample into its frame, which keeps mip level 0
5
+ // clean on an edge-to-edge sheet, but a mip chain averages 2^k texel
6
+ // blocks BEFORE any sampling decision, and a block straddling a cell edge
7
+ // mixes two cells. With a gutter of g replicated texels around every cell
8
+ // and every cell edge on a multiple of g, each block at level k <= log2(g)
9
+ // lies inside one cell's own colours, so levels 0 through log2(g) sample
10
+ // clean (deeper levels mix neighbours again; widen the gutter for a sheet
11
+ // drawn further below its texel size). CPU work at load time - a few
12
+ // milliseconds for a 1024-square sheet - and the result goes through
13
+ // createAtlas like any decoded image. No GPU or GUI imports (the
14
+ // DecodedImage import is a type), so tests/extrude.test.ts runs headless.
15
+ import type { DecodedImage } from "@solidrt/core"
16
+ import type { GridOptions } from "./frames.ts"
17
+ import { packRects, packWidth } from "./pack.ts"
18
+
19
+ // Bytes per pixel of a DecodedImage (tightly packed RGBA8).
20
+ const BYTES_PER_PIXEL = 4
21
+
22
+ /** A named pixel-rect table, `[x, y, w, h]` per name: namedFrames' input. */
23
+ export type Rects<K extends string> = Record<K, [number, number, number, number]>
24
+
25
+ /** extrudeGrid's result: the repacked sheet and the GridOptions that slice it. */
26
+ export type ExtrudedGrid = { image: DecodedImage; options: GridOptions }
27
+ /** extrudeRects' result: the repacked sheet and each rect's place in it. */
28
+ export type ExtrudedRects<K extends string> = { image: DecodedImage; rects: Rects<K> }
29
+
30
+ /**
31
+ * Extrude a uniform sheet: every cell of the `cols` x `rows` grid (laid
32
+ * out by `source`, the same geometry `grid` reads - cell size, spacing,
33
+ * margins; default the sheet divided evenly) is copied into a new sheet
34
+ * inside a `gutter`-texel border of its own replicated edge pixels.
35
+ * Returns the sheet and the GridOptions that slice it:
36
+ * `grid(atlas, cols, rows, extruded.options)` over
37
+ * `createAtlas(extruded.image, { mipmap: true })`.
38
+ *
39
+ * `gutter` is a power of two and the cell size a multiple of it, so cell
40
+ * edges land on mip texel edges; mip levels 0 through log2(gutter) then
41
+ * sample clean. The new sheet is `cell + 2 * gutter` per cell on each
42
+ * axis - a 16 px cell with a 4-texel gutter is 24 px, 2.25x the texels.
43
+ * Throws on a gutter or cell size that breaks the alignment, or a grid
44
+ * the sheet does not hold.
45
+ */
46
+ export function extrudeGrid(image: DecodedImage, cols: number, rows: number, gutter: number, source?: GridOptions): ExtrudedGrid {
47
+ checkImage("extrudeGrid", image)
48
+ if (!(cols > 0 && rows > 0 && Number.isInteger(cols) && Number.isInteger(rows))) {
49
+ throw new Error(`extrudeGrid: cols and rows must be positive integers, got ${cols} x ${rows}`)
50
+ }
51
+ checkGutter("extrudeGrid", gutter)
52
+ let { width, height } = image
53
+ let { spacing = 0, marginX = 0, marginY = 0 } = source ?? {}
54
+ let cellW = source?.cellW ?? (width - marginX * 2 - spacing * (cols - 1)) / cols
55
+ let cellH = source?.cellH ?? (height - marginY * 2 - spacing * (rows - 1)) / rows
56
+ checkCell("extrudeGrid", cellW, cellH, gutter)
57
+ if (marginX + cols * cellW + (cols - 1) * spacing > width || marginY + rows * cellH + (rows - 1) * spacing > height) {
58
+ throw new Error(`extrudeGrid: a ${cols} x ${rows} grid of ${cellW} x ${cellH} cells does not fit the ${width} x ${height} sheet`)
59
+ }
60
+ let pitchX = cellW + 2 * gutter
61
+ let pitchY = cellH + 2 * gutter
62
+ let out = blank(cols * pitchX, rows * pitchY)
63
+ for (let row = 0; row < rows; row++) {
64
+ for (let col = 0; col < cols; col++) {
65
+ copyCell(image, marginX + col * (cellW + spacing), marginY + row * (cellH + spacing), cellW, cellH, out, col * pitchX + gutter, row * pitchY + gutter, gutter)
66
+ }
67
+ }
68
+ return { image: out, options: { cellW, cellH, spacing: 2 * gutter, marginX: gutter, marginY: gutter } }
69
+ }
70
+
71
+ /**
72
+ * Extrude a hand-packed sheet: every named rect is re-placed on a new
73
+ * sheet (a shelf packing, pack.ts) inside a `gutter`-texel border of its
74
+ * own replicated edge pixels, and the table of new rects comes back for
75
+ * `namedFrames(atlas, extruded.rects)`. The same alignment rule as
76
+ * extrudeGrid: `gutter` a power of two, every rect's size a multiple of
77
+ * it (pad a rect in the source sheet to make it so). The new sheet is a
78
+ * power of two wide and as tall as its shelves.
79
+ */
80
+ export function extrudeRects<K extends string>(image: DecodedImage, rects: Rects<K>, gutter: number): ExtrudedRects<K> {
81
+ checkImage("extrudeRects", image)
82
+ checkGutter("extrudeRects", gutter)
83
+ let names = Object.keys(rects) as K[]
84
+ if (names.length === 0) throw new Error("extrudeRects: no rects to extrude")
85
+ let sizes = names.map(name => {
86
+ let [x, y, w, h] = rects[name]
87
+ checkCell(`extrudeRects: rect '${name}'`, w, h, gutter)
88
+ if (!(Number.isInteger(x) && Number.isInteger(y) && x >= 0 && y >= 0 && x + w <= image.width && y + h <= image.height)) {
89
+ throw new Error(`extrudeRects: rect '${name}' [${x}, ${y}, ${w}, ${h}] is not inside the ${image.width} x ${image.height} sheet`)
90
+ }
91
+ return { width: w + 2 * gutter, height: h + 2 * gutter }
92
+ })
93
+ let packing = packRects(sizes, packWidth(sizes))
94
+ let out = blank(packing.width, packing.height)
95
+ let placed = {} as Rects<K>
96
+ names.forEach((name, i) => {
97
+ let [x, y, w, h] = rects[name]
98
+ let p = packing.rects[i]!
99
+ copyCell(image, x, y, w, h, out, p.x + gutter, p.y + gutter, gutter)
100
+ placed[name] = [p.x + gutter, p.y + gutter, w, h]
101
+ })
102
+ return { image: out, rects: placed }
103
+ }
104
+
105
+ /** A transparent sheet of the given size. */
106
+ function blank(width: number, height: number): DecodedImage {
107
+ return { data: new Uint8Array(width * height * BYTES_PER_PIXEL), width, height }
108
+ }
109
+
110
+ /**
111
+ * Copy the `w` x `h` cell at (sx, sy) of `src` to (dx, dy) of `dst` inside
112
+ * a `g`-texel border replicating its edge pixels: each output row takes
113
+ * the nearest cell row in one bulk copy, then its first and last pixels
114
+ * fill the side gutters pixel by pixel.
115
+ */
116
+ function copyCell(src: DecodedImage, sx: number, sy: number, w: number, h: number, dst: DecodedImage, dx: number, dy: number, g: number): void {
117
+ let srcRow = src.width * BYTES_PER_PIXEL
118
+ let dstRow = dst.width * BYTES_PER_PIXEL
119
+ let cellBytes = w * BYTES_PER_PIXEL
120
+ for (let oy = -g; oy < h + g; oy++) {
121
+ let row = sy + Math.min(Math.max(oy, 0), h - 1)
122
+ let from = row * srcRow + sx * BYTES_PER_PIXEL
123
+ let to = (dy + oy) * dstRow + dx * BYTES_PER_PIXEL
124
+ dst.data.set(src.data.subarray(from, from + cellBytes), to)
125
+ let last = to + cellBytes - BYTES_PER_PIXEL
126
+ for (let i = 1; i <= g; i++) {
127
+ dst.data.copyWithin(to - i * BYTES_PER_PIXEL, to, to + BYTES_PER_PIXEL)
128
+ dst.data.copyWithin(last + i * BYTES_PER_PIXEL, last, last + BYTES_PER_PIXEL)
129
+ }
130
+ }
131
+ }
132
+
133
+ function checkImage(what: string, image: DecodedImage): void {
134
+ let { width, height, data } = image
135
+ if (!(Number.isInteger(width) && Number.isInteger(height) && width > 0 && height > 0)) {
136
+ throw new Error(`${what}: image size must be positive integers, got ${width} x ${height}`)
137
+ }
138
+ if (data.length !== width * height * BYTES_PER_PIXEL) {
139
+ throw new Error(`${what}: image data holds ${data.length} bytes, not the ${width * height * BYTES_PER_PIXEL} of ${width} x ${height} RGBA8 pixels`)
140
+ }
141
+ }
142
+
143
+ function checkGutter(what: string, gutter: number): void {
144
+ if (!(Number.isInteger(gutter) && gutter >= 1 && (gutter & (gutter - 1)) === 0)) {
145
+ throw new Error(`${what}: gutter must be a power of two >= 1, got ${gutter}`)
146
+ }
147
+ }
148
+
149
+ function checkCell(what: string, w: number, h: number, gutter: number): void {
150
+ if (!(Number.isInteger(w) && Number.isInteger(h) && w > 0 && h > 0)) {
151
+ throw new Error(`${what}: cell size must be positive integers, got ${w} x ${h}`)
152
+ }
153
+ if (w % gutter !== 0 || h % gutter !== 0) {
154
+ throw new Error(`${what}: a ${w} x ${h} cell is not a multiple of the ${gutter}-texel gutter, so its edges would not land on mip texel edges; pad the cell or lower the gutter`)
155
+ }
156
+ }
package/src/font.ts ADDED
@@ -0,0 +1,207 @@
1
+ // A sprite font: the runtime's glyph engine (`flux:font`) seen as an atlas
2
+ // a layer declares plus a frame per glyph. The engine shapes the text and
3
+ // makes the glyph cells on its worker thread; this keeps the cells as
4
+ // frames, asks for the ones a run needs, and re-frames the runs whose
5
+ // glyphs arrive - including every run when the atlas grew, which moves
6
+ // every cell (a growth is a size change of `font.atlas`). The font is a
7
+ // handle from the first call: its atlas texture exists at once, so a layer
8
+ // declares it at creation and draws from it as cells land, a label's
9
+ // unbaked glyphs drawing as nothing at the right advance until then.
10
+ //
11
+ // XNA's SpriteFont, PixiJS's BitmapFont: a font asset drawn as quads. Two
12
+ // cell kinds: "msdf" (the default, what a zooming label wants), one atlas
13
+ // sharp at every zoom with the layer's fragment stage decoding the field
14
+ // (`Atlas.sdf`), and "mask", coverage at the face's exact size for drawing
15
+ // 1:1.
16
+ import { getOwner, onCleanup } from "@solidrt/core"
17
+ import type { PreparedText } from "@solidrt/core"
18
+ import { createFont, destroyFont, fontAtlas, glyphCells, prepareText, requestGlyphs } from "flux:font"
19
+ import type { FontAtlasOptions, FontFace, FontId, FontPrepareOptions, GlyphCell } from "flux:font"
20
+ import type { Atlas } from "./atlas.ts"
21
+ import type { Frame } from "./frames.ts"
22
+
23
+ /** The printable ASCII range, warmed up by default: what labels, counters
24
+ * and HUD text are made of, so a run of them never waits for a cell. */
25
+ const ASCII_FIRST = 0x20
26
+ const ASCII_LAST = 0x7e
27
+
28
+ export type SpriteFontOptions = FontAtlasOptions & {
29
+ /**
30
+ * The characters to make cells for at creation (default printable
31
+ * ASCII; `false` for none): a run drawn later from these never waits. A
32
+ * run needing other glyphs makes them on demand and draws them when they
33
+ * land (`ready` awaits that).
34
+ */
35
+ chars?: string | false
36
+ /** Skip the owner-scoped auto-free (the core createTexture contract). */
37
+ autoFree?: boolean
38
+ }
39
+
40
+ /** One glyph as the atlas holds it: its frame, its cell size in texels
41
+ * and where the cell sits on the glyph (`left` right of the origin,
42
+ * `top` above the baseline, texels). */
43
+ export type Glyph = {
44
+ frame: Frame
45
+ width: number
46
+ height: number
47
+ left: number
48
+ top: number
49
+ }
50
+
51
+ /** What a text run registers with its font: the call that re-reads its
52
+ * glyphs after cells landed or moved. */
53
+ export type FontListener = { _reframe(grew: boolean, arrived: ReadonlySet<number>): void }
54
+
55
+ export type SpriteFont = {
56
+ /** The engine's handle. */
57
+ readonly id: FontId
58
+ /**
59
+ * The atlas a layer declares (`createSpriteLayer([art, font.atlas])`):
60
+ * one record for the font's life whose `width`/`height` change in place
61
+ * as the atlas grows, `sdf` set for msdf cells.
62
+ */
63
+ readonly atlas: Atlas
64
+ /** Texels per em the cells were made at: a glyph drawn at `fontSize`
65
+ * scales its cell by `fontSize / size`. */
66
+ readonly size: number
67
+ /** The face's size: what `prepare` shapes at when the call names none. */
68
+ readonly fontSize: number
69
+ /** Shape `text` on the engine (see flux:font prepareText): every unit
70
+ * carries its glyphs, for `layoutText`. */
71
+ prepare(text: string, options?: FontPrepareOptions): PreparedText
72
+ /** The glyph's frame and placement, or null while its cell is not in
73
+ * the atlas (request it with `request`). */
74
+ glyph(id: number): Glyph | null
75
+ /** Ask for the cells of `ids` the atlas lacks; they land at a later
76
+ * frame and every listening run is re-framed. */
77
+ request(ids: Iterable<number>): void
78
+ /** Resolves once no cells are in the making: the font is drawable as
79
+ * asked. */
80
+ ready(): Promise<void>
81
+ /** Free the engine font and its atlas texture; runs over it must be
82
+ * gone. */
83
+ dispose(): void
84
+ /** Internal: the runs to re-frame when cells land. */
85
+ _listeners: Set<FontListener>
86
+ }
87
+
88
+ /**
89
+ * Create a sprite font over a registered face (a role alias such as
90
+ * "sans" or a registered family, the weight and style, and the size a
91
+ * mask atlas is made at and text shapes at by default). Printable ASCII
92
+ * is made right away; `ready()` tells when. Freed with the owning
93
+ * reactive scope like a texture (opt out with `{ autoFree: false }`).
94
+ */
95
+ export function createSpriteFont(face: FontFace, opts?: SpriteFontOptions): SpriteFont {
96
+ let { chars, autoFree, ...atlasOptions } = opts ?? {}
97
+ let id = createFont(face, { cells: "msdf", ...atlasOptions })
98
+ let info = fontAtlas(id)
99
+ let atlas: Atlas = { texture: info.texture, width: info.width, height: info.height }
100
+ if (info.cells === "msdf") atlas.sdf = { range: info.range }
101
+ let glyphs = new Map<number, Glyph>()
102
+ let known = new Set<number>()
103
+ // Requests in flight, and who waits for all of them to land.
104
+ let inFlight = 0
105
+ let waiting: (() => void)[] = []
106
+ let disposed = false
107
+
108
+ let toGlyph = (cell: GlyphCell): Glyph => ({
109
+ frame: {
110
+ texture: atlas.texture,
111
+ u0: cell.x / atlas.width,
112
+ v0: cell.y / atlas.height,
113
+ u1: (cell.x + cell.width) / atlas.width,
114
+ v1: (cell.y + cell.height) / atlas.height,
115
+ },
116
+ width: cell.width,
117
+ height: cell.height,
118
+ left: cell.left,
119
+ top: cell.top,
120
+ })
121
+
122
+ // Cells landed: take the atlas's size (a change means every cell moved
123
+ // and every frame is re-cut), record the arrivals, tell the runs.
124
+ let landed = (ids: number[], cells: (GlyphCell | null)[]) => {
125
+ if (disposed) return
126
+ let now = fontAtlas(id)
127
+ let grew = now.width !== atlas.width || now.height !== atlas.height
128
+ atlas.width = now.width
129
+ atlas.height = now.height
130
+ let arrived = new Set<number>()
131
+ if (grew) {
132
+ let all = [...known]
133
+ glyphs.clear()
134
+ glyphCells(id, all).forEach((cell, i) => {
135
+ if (cell !== null) glyphs.set(all[i]!, toGlyph(cell))
136
+ })
137
+ }
138
+ cells.forEach((cell, i) => {
139
+ // A failed glyph (null) stays absent: drawn as nothing at its advance.
140
+ if (cell !== null) glyphs.set(ids[i]!, toGlyph(cell))
141
+ arrived.add(ids[i]!)
142
+ })
143
+ for (let listener of font._listeners) listener._reframe(grew, arrived)
144
+ }
145
+
146
+ let font: SpriteFont = {
147
+ id,
148
+ atlas,
149
+ size: info.size,
150
+ fontSize: info.fontSize,
151
+ _listeners: new Set(),
152
+ prepare(text, options) {
153
+ if (disposed) throw new Error("prepare: the sprite font is disposed")
154
+ return prepareText(id, text, options)
155
+ },
156
+ glyph(glyph) {
157
+ return glyphs.get(glyph) ?? null
158
+ },
159
+ request(ids) {
160
+ if (disposed) return
161
+ let missing: number[] = []
162
+ for (let glyph of ids) {
163
+ if (!known.has(glyph)) {
164
+ known.add(glyph)
165
+ missing.push(glyph)
166
+ }
167
+ }
168
+ if (missing.length === 0) return
169
+ inFlight++
170
+ requestGlyphs(id, missing)
171
+ .then(cells => landed(missing, cells))
172
+ .catch(error => console.warn(`[2d] glyph request failed: ${error}`))
173
+ .finally(() => {
174
+ inFlight--
175
+ if (inFlight === 0) {
176
+ let resolve = waiting
177
+ waiting = []
178
+ for (let r of resolve) r()
179
+ }
180
+ })
181
+ },
182
+ ready() {
183
+ if (inFlight === 0) return Promise.resolve()
184
+ return new Promise(resolve => waiting.push(resolve))
185
+ },
186
+ dispose() {
187
+ if (disposed) return
188
+ disposed = true
189
+ font._listeners.clear()
190
+ destroyFont(id)
191
+ },
192
+ }
193
+ if (chars !== false) {
194
+ let warm = chars ?? ascii()
195
+ let ids = new Set<number>()
196
+ for (let unit of font.prepare(warm).units) for (let g of unit.glyphs ?? []) ids.add(g.id)
197
+ font.request(ids)
198
+ }
199
+ if (autoFree !== false && getOwner()) onCleanup(() => font.dispose())
200
+ return font
201
+ }
202
+
203
+ function ascii(): string {
204
+ let out = ""
205
+ for (let c = ASCII_FIRST; c <= ASCII_LAST; c++) out += String.fromCharCode(c)
206
+ return out
207
+ }