@solidrt/2d 0.0.67 → 0.0.69
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/AGENTS.md +246 -69
- package/README.md +33 -18
- package/examples/README.md +29 -0
- package/examples/anim.tsx +1 -1
- package/examples/atlases.tsx +151 -0
- package/examples/camera-probe.tsx +7 -7
- package/examples/camera.tsx +2 -3
- package/examples/markers.tsx +185 -0
- package/examples/parity.tsx +2 -2
- package/examples/pick.tsx +1 -1
- package/examples/split-screen.tsx +1 -1
- package/examples/springs.tsx +1 -1
- package/examples/sprites.tsx +1 -1
- package/examples/text.tsx +143 -0
- package/examples/tiles.tsx +1 -1
- package/examples/views.tsx +3 -3
- package/package.json +3 -3
- package/src/atlas.ts +75 -12
- package/src/camera2d.ts +47 -34
- package/src/collision.ts +20 -0
- package/src/components/camera2d.tsx +6 -13
- package/src/components/group.tsx +5 -10
- package/src/components/index.ts +3 -1
- package/src/components/sprite-layer.tsx +47 -14
- package/src/components/sprite.tsx +15 -7
- package/src/components/text2d.tsx +77 -0
- package/src/components/tile-layer.tsx +11 -5
- package/src/dispatch.ts +3 -2
- package/src/extrude.ts +156 -0
- package/src/font.ts +207 -0
- package/src/frames.ts +65 -55
- package/src/index.ts +37 -18
- package/src/layer.ts +267 -45
- package/src/pack.ts +70 -0
- package/src/pick.ts +25 -1
- package/src/records.ts +286 -121
- package/src/shaders.ts +224 -77
- package/src/shots.ts +23 -9
- package/src/text-layout.ts +163 -0
- package/src/text.ts +240 -0
- package/src/tiles.ts +111 -71
- package/src/views.ts +20 -13
|
@@ -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 {
|
|
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
|
|
28
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|