vgpu-render 0.0.0-stage → 0.2.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 (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +605 -2
  3. package/dist/assets/fira-mono-medium-subset.d.ts +4 -0
  4. package/dist/assets/fira-mono-medium-subset.js +889 -0
  5. package/dist/assets/fira-mono-msdf-atlas.d.ts +13 -0
  6. package/dist/assets/fira-mono-msdf-atlas.js +6222 -0
  7. package/dist/camera-2d.d.ts +43 -0
  8. package/dist/camera-2d.js +75 -0
  9. package/dist/color-space.d.ts +32 -0
  10. package/dist/color-space.js +45 -0
  11. package/dist/curve.d.ts +16 -0
  12. package/dist/curve.js +46 -0
  13. package/dist/debug-draw-3d.d.ts +14 -0
  14. package/dist/debug-draw-3d.js +36 -0
  15. package/dist/frame-draw-list-2d.d.ts +177 -0
  16. package/dist/frame-draw-list-2d.js +378 -0
  17. package/dist/frame-draw-list.d.ts +413 -0
  18. package/dist/frame-draw-list.js +556 -0
  19. package/dist/geometry-generators.d.ts +101 -0
  20. package/dist/geometry-generators.js +481 -0
  21. package/dist/gpu-debug-draw-2d.d.ts +48 -0
  22. package/dist/gpu-debug-draw-2d.js +450 -0
  23. package/dist/gpu-debug-draw-3d.d.ts +52 -0
  24. package/dist/gpu-debug-draw-3d.js +892 -0
  25. package/dist/gpu-instancing.d.ts +34 -0
  26. package/dist/gpu-instancing.js +93 -0
  27. package/dist/gpu-text-3d.d.ts +98 -0
  28. package/dist/gpu-text-3d.js +514 -0
  29. package/dist/index.d.ts +21 -0
  30. package/dist/index.js +20 -0
  31. package/dist/infinite-grid-2d.d.ts +73 -0
  32. package/dist/infinite-grid-2d.js +253 -0
  33. package/dist/infinite-grid-3d.d.ts +134 -0
  34. package/dist/infinite-grid-3d.js +407 -0
  35. package/dist/mesh-batch-3d.d.ts +10 -0
  36. package/dist/mesh-batch-3d.js +67 -0
  37. package/dist/shape2d-shader.d.ts +14 -0
  38. package/dist/shape2d-shader.js +288 -0
  39. package/dist/text-atlas.d.ts +176 -0
  40. package/dist/text-atlas.js +498 -0
  41. package/dist/text-font.d.ts +190 -0
  42. package/dist/text-font.js +261 -0
  43. package/dist/text-layout.d.ts +175 -0
  44. package/dist/text-layout.js +390 -0
  45. package/dist/text-msdf-baked.d.ts +41 -0
  46. package/dist/text-msdf-baked.js +117 -0
  47. package/dist/text-msdf-font.d.ts +85 -0
  48. package/dist/text-msdf-font.js +159 -0
  49. package/dist/text-msdf.d.ts +70 -0
  50. package/dist/text-msdf.js +242 -0
  51. package/dist/text-raster.d.ts +40 -0
  52. package/dist/text-raster.js +60 -0
  53. package/dist/text-sdf.d.ts +28 -0
  54. package/dist/text-sdf.js +119 -0
  55. package/dist/vector-math.d.ts +19 -0
  56. package/dist/vector-math.js +37 -0
  57. package/package.json +80 -4
@@ -0,0 +1,390 @@
1
+ /**
2
+ * Text layout for the debug text renderer.
3
+ *
4
+ * Ported from reference `TextPipeline::update_text_layout_info`
5
+ * (`the reference implementation`): a text block is wrapped to its `TextBounds`, split
6
+ * into lines, justified, and turned into one positioned glyph per character. Each glyph
7
+ * carries its ink box and resolved colour, which is what the GPU pass later instantiates as
8
+ * a quad.
9
+ *
10
+ * Two deliberate differences from reference:
11
+ *
12
+ * - There is no shaping. Characters advance one at a time and never form ligatures or
13
+ * reorder runs, which suits the monospaced debug font the library bundles.
14
+ * - Every metric comes from an injected `TextMeasurer`. reference reads parley and swash here;
15
+ * staying behind an interface keeps this module free of browser APIs and unit-testable in
16
+ * plain Node, and it is also the seam the GPU tests use to feed synthetic glyphs.
17
+ *
18
+ * Layout happens in one local space: origin at the block's anchor point, x to the right and
19
+ * **y down**, matching Canvas2D. The renderer flips y for spaces where up is +y.
20
+ */
21
+ /** Every `FontWidth` stop, in reference order. */
22
+ export const TEXT_FONT_WIDTHS = [
23
+ "ultra-condensed",
24
+ "extra-condensed",
25
+ "condensed",
26
+ "semi-condensed",
27
+ "normal",
28
+ "semi-expanded",
29
+ "expanded",
30
+ "extra-expanded",
31
+ "ultra-expanded",
32
+ ];
33
+ /** Every `FontStyle` variant this implementation can honour. */
34
+ export const TEXT_FONT_STYLES = ["normal", "italic"];
35
+ /** reference `FontWeight::NORMAL`, and the value the CSS font shorthand defaults to. */
36
+ export const DEFAULT_FONT_WEIGHT = 400;
37
+ /** reference `FontWeight` range, 1 to 1000. */
38
+ export const MIN_FONT_WEIGHT = 1;
39
+ export const MAX_FONT_WEIGHT = 1000;
40
+ /** Folds a face's optional axes into the complete request the measurer and the atlas take. */
41
+ export function resolveFontFace(options) {
42
+ return {
43
+ family: options.family,
44
+ weight: options.weight ?? DEFAULT_FONT_WEIGHT,
45
+ style: options.style ?? "normal",
46
+ width: options.width ?? "normal",
47
+ };
48
+ }
49
+ /** Every field that can change which glyphs are rasterised, joined into one cache key. */
50
+ export function fontFaceKey(face) {
51
+ return `${face.family}|${face.weight}|${face.width}|${face.style}`;
52
+ }
53
+ const TEXT_ANCHORS = {
54
+ center: [0.5, 0.5],
55
+ "top-left": [0, 0],
56
+ "top-center": [0.5, 0],
57
+ "top-right": [1, 0],
58
+ "center-left": [0, 0.5],
59
+ "center-right": [1, 0.5],
60
+ "bottom-left": [0, 1],
61
+ "bottom-center": [0.5, 1],
62
+ "bottom-right": [1, 1],
63
+ };
64
+ export function resolveTextAnchor(anchor) {
65
+ const point = TEXT_ANCHORS[anchor];
66
+ if (point === undefined) {
67
+ throw new RangeError(`unknown text anchor "${anchor}"`);
68
+ }
69
+ return point;
70
+ }
71
+ /** reference `LineHeight` default, `RelativeToFont(1.2)`. */
72
+ export const DEFAULT_LINE_HEIGHT_SCALE = 1.2;
73
+ function isWhitespace(character) {
74
+ return character === " " || character === "\t";
75
+ }
76
+ /**
77
+ * Characters that break between themselves rather than at spaces.
78
+ *
79
+ * UAX #14 calls these classes ID (ideographic) and the punctuation around them. Without this a whole
80
+ * Chinese sentence arrives as one word token, so `word` breaking can only move it as a block and a
81
+ * bounded block overflows its bounds instead of wrapping.
82
+ */
83
+ function isCjkCharacter(character) {
84
+ const code = character.codePointAt(0) ?? 0;
85
+ return ((code >= 0x2e80 && code <= 0x303f) || // CJK radicals, symbols and punctuation
86
+ (code >= 0x3040 && code <= 0x30ff) || // hiragana, katakana
87
+ (code >= 0x3400 && code <= 0x4dbf) || // CJK unified ideographs extension A
88
+ (code >= 0x4e00 && code <= 0x9fff) || // CJK unified ideographs
89
+ (code >= 0xf900 && code <= 0xfaff) || // CJK compatibility ideographs
90
+ (code >= 0xff00 && code <= 0xffef) || // full-width forms
91
+ (code >= 0xac00 && code <= 0xd7af) || // hangul syllables
92
+ (code >= 0x20000 && code <= 0x3ffff) // extensions B and beyond
93
+ );
94
+ }
95
+ /**
96
+ * Kinsoku (禁则) sets: punctuation that may not open a line, and brackets that may not close one.
97
+ *
98
+ * The line breaker only knows how to move a token, so the rule is expressed by gluing the character
99
+ * to its neighbour instead: a closing mark joins the run before it, an opening mark the run after.
100
+ */
101
+ const NO_LINE_START = new Set([
102
+ ",", ".", "!", "?", ":", ";", ")", "]", "}", ".", ")", "]", "}", "%",
103
+ "\u3001", "\u3002", "\uff0c", "\uff0e", "\uff01", "\uff1f", "\uff1a", "\uff1b", "\uff09", "\uff3d", "\uff5d", "\uff05",
104
+ "\u300d", "\u300f", "\u3011", "\u300b", "\u3009", "\u3015", "\u2019", "\u201d",
105
+ ]);
106
+ const NO_LINE_END = new Set(["(", "[", "{", "\uff08", "\uff3b", "\uff5b", "\u300c", "\u300e", "\u3010", "\u300a", "\u3008", "\u3014", "\u2018", "\u201c"]);
107
+ /** Appends one character to the token that is already open, or starts a new one. */
108
+ function glueToLastToken(tokens, character) {
109
+ const last = tokens[tokens.length - 1];
110
+ if (last === undefined || last.kind === "break") {
111
+ tokens.push({ kind: "word", characters: [character] });
112
+ return;
113
+ }
114
+ tokens[tokens.length - 1] = { kind: last.kind, characters: [...last.characters, character] };
115
+ }
116
+ /** Splits the flattened block into word / spaces / hard-break tokens. */
117
+ function tokenize(items) {
118
+ const tokens = [];
119
+ let current = [];
120
+ let kind;
121
+ const flush = () => {
122
+ if (kind !== undefined && current.length > 0)
123
+ tokens.push({ kind, characters: current });
124
+ current = [];
125
+ kind = undefined;
126
+ };
127
+ for (const item of items) {
128
+ if (item.character === "\n") {
129
+ flush();
130
+ tokens.push({ kind: "break", characters: [] });
131
+ continue;
132
+ }
133
+ // CJK text carries no spaces to break at, so each ideograph becomes its own token and the greedy
134
+ // breaker can wrap between any two of them. Kinsoku is applied here rather than in the breaker
135
+ // because it can only move a whole token: a mark that may not open a line is glued to the run it
136
+ // belongs to, and one that may not close a line to the character that follows it.
137
+ if (!item.isSpace && isCjkCharacter(item.character)) {
138
+ const previous = current[current.length - 1] ?? tokens[tokens.length - 1]?.characters.at(-1);
139
+ const glue = previous !== undefined && (NO_LINE_START.has(item.character) || NO_LINE_END.has(previous.character));
140
+ if (glue) {
141
+ if (current.length > 0)
142
+ current.push(item);
143
+ else
144
+ glueToLastToken(tokens, item);
145
+ kind = "word";
146
+ continue;
147
+ }
148
+ flush();
149
+ tokens.push({ kind: "word", characters: [item] });
150
+ continue;
151
+ }
152
+ const next = item.isSpace ? "space" : "word";
153
+ if (kind !== undefined && next !== kind)
154
+ flush();
155
+ kind = next;
156
+ current.push(item);
157
+ }
158
+ flush();
159
+ return tokens;
160
+ }
161
+ /** Advance of a run. `letterSpacing` lands after every character, the last one included. */
162
+ function runWidth(characters, letterSpacing) {
163
+ let width = 0;
164
+ for (const character of characters)
165
+ width += character.advance + letterSpacing;
166
+ return width;
167
+ }
168
+ /** Measures one broken line into the two advances and the line box size reference works from. */
169
+ function measureLine(characters, letterSpacing) {
170
+ // UAX #14 never leaves a space at the start of a line, and the breakers reference runs drop it there.
171
+ let start = 0;
172
+ while (start < characters.length && characters[start].isSpace)
173
+ start++;
174
+ const content = start === 0 ? characters : characters.slice(start);
175
+ let end = content.length;
176
+ while (end > 0 && content[end - 1].isSpace)
177
+ end--;
178
+ let fontSize = 0;
179
+ let face;
180
+ // The line box follows the line's own largest run, so the face recorded here is that run's.
181
+ for (const character of content) {
182
+ if (character.fontSize > fontSize) {
183
+ fontSize = character.fontSize;
184
+ face = character.face;
185
+ }
186
+ }
187
+ return {
188
+ characters: content.slice(0, end),
189
+ advance: runWidth(content.slice(0, end), letterSpacing),
190
+ fullAdvance: runWidth(content, letterSpacing),
191
+ fontSize,
192
+ face,
193
+ isEmpty: content.length === 0,
194
+ };
195
+ }
196
+ /** Splits a word token one character at a time, the fallback for `word-or-character`. */
197
+ function splitCharacters(token) {
198
+ return token.characters.map((character) => ({ kind: "word", characters: [character] }));
199
+ }
200
+ /**
201
+ * Greedy line breaker. Words move whole to the next line when they no longer fit, matching
202
+ * reference `LineBreak::WordBoundary`; `character` strategies hand single characters to the same
203
+ * loop, and `none` only honours explicit newlines.
204
+ */
205
+ function breakLines(tokens, maxWidth, lineBreak, letterSpacing) {
206
+ const lines = [];
207
+ let line = [];
208
+ const pushLine = () => {
209
+ lines.push(line);
210
+ line = [];
211
+ };
212
+ const addCharacters = (characters) => {
213
+ for (const character of characters) {
214
+ if (maxWidth !== undefined && line.length > 0 && lineWidth(line, letterSpacing) + character.advance + letterSpacing > maxWidth) {
215
+ // The space a line breaks at stays on that line as trailing whitespace — it may even take the
216
+ // line past its bounds, which is why a bounded block can report a wider width. Carrying it to
217
+ // the next line instead would push the following characters right by one space.
218
+ if (character.isSpace)
219
+ line.push(character);
220
+ lines.push(line);
221
+ line = [];
222
+ if (character.isSpace)
223
+ continue;
224
+ }
225
+ line.push(character);
226
+ }
227
+ };
228
+ for (const token of tokens) {
229
+ if (token.kind === "break") {
230
+ pushLine();
231
+ continue;
232
+ }
233
+ if (maxWidth === undefined || lineBreak === "none") {
234
+ line.push(...token.characters);
235
+ continue;
236
+ }
237
+ if (lineBreak === "character") {
238
+ addCharacters(token.characters);
239
+ continue;
240
+ }
241
+ const width = lineWidth(line, letterSpacing) + runWidth(token.characters, letterSpacing);
242
+ const fits = width <= maxWidth || lineWidth(line, letterSpacing) === 0;
243
+ if (fits) {
244
+ line.push(...token.characters);
245
+ continue;
246
+ }
247
+ if (token.kind === "space") {
248
+ // A space that no longer fits ends the line and belongs to it, as the trailing whitespace
249
+ // parley keeps for its width even though it is not drawn.
250
+ line.push(...token.characters);
251
+ lines.push(line);
252
+ line = [];
253
+ continue;
254
+ }
255
+ // A word too wide for an empty line falls back to per-character breaking.
256
+ lines.push(line);
257
+ line = [];
258
+ const splittable = lineBreak === "word-or-character" || runWidth(token.characters, letterSpacing) > maxWidth;
259
+ if (splittable)
260
+ addCharacters(token.characters);
261
+ else
262
+ line.push(...token.characters);
263
+ }
264
+ pushLine();
265
+ return lines.map((characters) => measureLine(characters, letterSpacing));
266
+ }
267
+ function lineWidth(characters, letterSpacing) {
268
+ return runWidth(characters, letterSpacing);
269
+ }
270
+ function countSpaces(characters) {
271
+ let count = 0;
272
+ for (const character of characters)
273
+ if (character.isSpace)
274
+ count++;
275
+ return count;
276
+ }
277
+ /**
278
+ * Lays out one text block. `options.anchor` decides which part of the block lands on the
279
+ * position the renderer is given; the returned glyph centres are already offset for it.
280
+ */
281
+ export function layoutText(spans, options, measurer) {
282
+ const items = [];
283
+ const blockFace = resolveFontFace({
284
+ family: options.fontFamily,
285
+ weight: options.weight,
286
+ style: options.style,
287
+ width: options.width,
288
+ });
289
+ for (const span of spans) {
290
+ const fontSize = span.fontSize ?? options.fontSize;
291
+ const face = resolveFontFace({
292
+ family: span.fontFamily ?? options.fontFamily,
293
+ weight: span.weight ?? options.weight,
294
+ style: span.style ?? options.style,
295
+ width: span.width ?? options.width,
296
+ });
297
+ const color = span.color ?? options.color;
298
+ // Iterating the string yields code points, so surrogate pairs stay whole.
299
+ for (const character of span.text) {
300
+ if (character === "\n") {
301
+ items.push({ character, face, fontSize, color, advance: 0, width: 0, height: 0, bearingLeft: 0, bearingTop: 0, isSpace: false });
302
+ continue;
303
+ }
304
+ const metrics = measurer.glyphMetrics(character, face, fontSize);
305
+ items.push({
306
+ character,
307
+ face,
308
+ fontSize,
309
+ color,
310
+ advance: metrics.advance,
311
+ width: metrics.width,
312
+ height: metrics.height,
313
+ bearingLeft: metrics.bearingLeft,
314
+ bearingTop: metrics.bearingTop,
315
+ isSpace: isWhitespace(character),
316
+ });
317
+ }
318
+ }
319
+ const maxWidth = options.bounds?.[0];
320
+ const lines = breakLines(tokenize(items), maxWidth, options.lineBreak, options.letterSpacing);
321
+ // reference resolves the line box per line, from that line's own largest run: a relative
322
+ // `LineHeight` multiplies the run's size, so a line holding a larger span grows its box
323
+ // instead of being held to the block's default size.
324
+ const boxes = lines.map((line) => {
325
+ const fontSize = line.fontSize > 0 ? line.fontSize : options.fontSize;
326
+ const base = measurer.verticalMetrics(line.face ?? blockFace, fontSize);
327
+ const height = options.lineHeight ?? (options.lineHeightScale ?? DEFAULT_LINE_HEIGHT_SCALE) * fontSize;
328
+ return { base, height, halfLeading: (height - (base.ascent + base.descent)) / 2 };
329
+ });
330
+ // The block is what reference reports and anchors on: `Layout::full_width`, which keeps the
331
+ // whitespace each line broke at, over the summed line boxes. Both are rounded up.
332
+ const blockWidth = Math.ceil(lines.reduce((widest, line) => Math.max(widest, line.fullAdvance), 0));
333
+ let blockHeight = boxes.reduce((sum, box) => sum + box.height, 0);
334
+ // parley leaves a trailing empty line's box out of the layout height.
335
+ if (lines.length > 1 && lines[lines.length - 1].isEmpty)
336
+ blockHeight -= boxes[boxes.length - 1].height;
337
+ blockHeight = Math.ceil(blockHeight);
338
+ const offsetX = -options.anchor[0] * blockWidth;
339
+ const offsetY = -options.anchor[1] * blockHeight;
340
+ // `Alignment` works inside parley's `max_advance`: the bounds width when bounded, otherwise the
341
+ // widest line. That is a smaller box than `full_width` whenever a line breaks at a space.
342
+ const alignBox = maxWidth ?? lines.reduce((widest, line) => Math.max(widest, line.advance), 0);
343
+ const glyphs = [];
344
+ let penY = 0;
345
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
346
+ const line = lines[lineIndex];
347
+ const box = boxes[lineIndex];
348
+ const isLastLine = lineIndex === lines.length - 1;
349
+ let penX = alignPenX(options.justify, line.advance, alignBox);
350
+ let stretch = 0;
351
+ if (options.justify === "justified" && !isLastLine && maxWidth !== undefined) {
352
+ const spaces = countSpaces(line.characters);
353
+ if (spaces > 0 && alignBox > line.advance) {
354
+ stretch = (alignBox - line.advance) / spaces;
355
+ penX = 0;
356
+ }
357
+ }
358
+ const baseline = penY + box.halfLeading + box.base.ascent;
359
+ for (const character of line.characters) {
360
+ if (character.width > 0 && character.height > 0) {
361
+ glyphs.push({
362
+ character: character.character,
363
+ face: character.face,
364
+ fontSize: character.fontSize,
365
+ color: character.color,
366
+ center: [offsetX + penX + character.bearingLeft + character.width / 2, offsetY + baseline + character.bearingTop + character.height / 2],
367
+ width: character.width,
368
+ height: character.height,
369
+ });
370
+ }
371
+ penX += character.advance + options.letterSpacing;
372
+ if (character.isSpace)
373
+ penX += stretch;
374
+ }
375
+ penY += box.height;
376
+ }
377
+ return { glyphs, size: [blockWidth, blockHeight], lineCount: lines.length };
378
+ }
379
+ /** Reference `Justify` → the pen offset for one line inside the box `Alignment` gets. */
380
+ function alignPenX(justify, lineWidth, box) {
381
+ switch (justify) {
382
+ case "center":
383
+ return (box - lineWidth) / 2;
384
+ case "right":
385
+ case "end":
386
+ return box - lineWidth;
387
+ default:
388
+ return 0;
389
+ }
390
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Prebaked multi-channel distance field glyph source.
3
+ *
4
+ * The counterpart to `OutlineGlyphRasterizer`: instead of parsing a face and running the encoder on
5
+ * the first frame a glyph appears, this reads the atlas that `bun scripts/bake-msdf-atlas.ts`
6
+ * produced and slices each glyph out of it. Nothing here links `opentype.js`, and no encoding ever
7
+ * runs — at the cost of a fixed character set and a payload that ships whether or not it is used.
8
+ *
9
+ * The page travels gzipped, so `load()` has to resolve before any glyph is asked for. Decompression
10
+ * uses the platform `DecompressionStream`, which is why the payload is not a PNG: it keeps the whole
11
+ * path free of image decoding and usable in Node, where the tests run.
12
+ *
13
+ * Because the page stores every glyph at one fixed raster, the metrics it carries are scaled by
14
+ * `fontSize / bakeSize` rather than re-rasterised. That is exactly what makes a distance field worth
15
+ * baking: one set of texels serves every size.
16
+ */
17
+ import { type GlyphRasterizer, type RasterizedGlyph } from "./text-font.js";
18
+ import type { FontFace, GlyphMetrics, TextMeasurer, VerticalMetrics } from "./text-layout.js";
19
+ export declare class BakedMsdfRasterizer implements GlyphRasterizer, TextMeasurer {
20
+ #private;
21
+ readonly encoding: "msdf";
22
+ readonly distanceRange = 4;
23
+ readonly bakeSize = 24;
24
+ /**
25
+ * Every glyph already sits in a page baked at `bakeSize`, so another raster size cannot be
26
+ * honoured: the renderer keeps this source on its own raster instead of following the screen.
27
+ */
28
+ readonly dynamicRaster = false;
29
+ /** True once the page is decompressed and glyphs can be sliced out of it. */
30
+ get ready(): boolean;
31
+ /** Decompresses the inlined page. Idempotent, so calling it per frame is harmless. */
32
+ load(): Promise<void>;
33
+ /**
34
+ * The page stores one face, so the axes cannot change a sliced glyph. Keying on the family alone
35
+ * keeps the atlas from packing the same texels once per requested weight.
36
+ */
37
+ readonly faceKey: (face: FontFace) => string;
38
+ verticalMetrics(_face: FontFace, fontSize: number): VerticalMetrics;
39
+ glyphMetrics(character: string, _face: FontFace, fontSize: number): GlyphMetrics;
40
+ rasterize(character: string, face: FontFace, fontSize: number): RasterizedGlyph | undefined;
41
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Prebaked multi-channel distance field glyph source.
3
+ *
4
+ * The counterpart to `OutlineGlyphRasterizer`: instead of parsing a face and running the encoder on
5
+ * the first frame a glyph appears, this reads the atlas that `bun scripts/bake-msdf-atlas.ts`
6
+ * produced and slices each glyph out of it. Nothing here links `opentype.js`, and no encoding ever
7
+ * runs — at the cost of a fixed character set and a payload that ships whether or not it is used.
8
+ *
9
+ * The page travels gzipped, so `load()` has to resolve before any glyph is asked for. Decompression
10
+ * uses the platform `DecompressionStream`, which is why the payload is not a PNG: it keeps the whole
11
+ * path free of image decoding and usable in Node, where the tests run.
12
+ *
13
+ * Because the page stores every glyph at one fixed raster, the metrics it carries are scaled by
14
+ * `fontSize / bakeSize` rather than re-rasterised. That is exactly what makes a distance field worth
15
+ * baking: one set of texels serves every size.
16
+ */
17
+ import { BAKED_MSDF_ASCENT, BAKED_MSDF_BAKE_SIZE, BAKED_MSDF_DESCENT, BAKED_MSDF_DISTANCE_RANGE, BAKED_MSDF_GLYPHS, BAKED_MSDF_PAGE_GZIP_BASE64, BAKED_MSDF_PAGE_SIZE, } from "./assets/fira-mono-msdf-atlas.js";
18
+ import { decodeBase64Bytes } from "./text-font.js";
19
+ function bakedGlyph(character) {
20
+ return BAKED_MSDF_GLYPHS[character];
21
+ }
22
+ export class BakedMsdfRasterizer {
23
+ encoding = "msdf";
24
+ distanceRange = BAKED_MSDF_DISTANCE_RANGE;
25
+ bakeSize = BAKED_MSDF_BAKE_SIZE;
26
+ /**
27
+ * Every glyph already sits in a page baked at `bakeSize`, so another raster size cannot be
28
+ * honoured: the renderer keeps this source on its own raster instead of following the screen.
29
+ */
30
+ dynamicRaster = false;
31
+ #metrics = new Map();
32
+ #page;
33
+ /** True once the page is decompressed and glyphs can be sliced out of it. */
34
+ get ready() {
35
+ return this.#page !== undefined;
36
+ }
37
+ /** Decompresses the inlined page. Idempotent, so calling it per frame is harmless. */
38
+ async load() {
39
+ if (this.#page !== undefined)
40
+ return;
41
+ const compressed = decodeBase64Bytes(BAKED_MSDF_PAGE_GZIP_BASE64);
42
+ const stream = new Blob([compressed]).stream().pipeThrough(new DecompressionStream("gzip"));
43
+ const buffer = await new Response(stream).arrayBuffer();
44
+ const page = new Uint8Array(buffer);
45
+ const expected = BAKED_MSDF_PAGE_SIZE * BAKED_MSDF_PAGE_SIZE * 4;
46
+ if (page.byteLength !== expected) {
47
+ throw new Error(`the prebaked atlas decompressed to ${page.byteLength} bytes, expected ${expected}`);
48
+ }
49
+ this.#page = page;
50
+ }
51
+ /**
52
+ * The page stores one face, so the axes cannot change a sliced glyph. Keying on the family alone
53
+ * keeps the atlas from packing the same texels once per requested weight.
54
+ */
55
+ faceKey = (face) => face.family;
56
+ verticalMetrics(_face, fontSize) {
57
+ const scale = fontSize / BAKED_MSDF_BAKE_SIZE;
58
+ return { ascent: BAKED_MSDF_ASCENT * scale, descent: BAKED_MSDF_DESCENT * scale };
59
+ }
60
+ glyphMetrics(character, _face, fontSize) {
61
+ const key = `${fontSize}|${character}`;
62
+ const cached = this.#metrics.get(key);
63
+ if (cached !== undefined)
64
+ return cached;
65
+ const baked = bakedGlyph(character);
66
+ const scale = fontSize / BAKED_MSDF_BAKE_SIZE;
67
+ const value = baked === undefined
68
+ ? { advance: 0, width: 0, height: 0, bearingLeft: 0, bearingTop: 0 }
69
+ : {
70
+ advance: baked[8] * scale,
71
+ width: baked[6] * scale,
72
+ height: baked[7] * scale,
73
+ bearingLeft: baked[4] * scale,
74
+ bearingTop: baked[5] * scale,
75
+ };
76
+ this.#metrics.set(key, value);
77
+ return value;
78
+ }
79
+ rasterize(character, face, fontSize) {
80
+ const page = this.#page;
81
+ if (page === undefined)
82
+ throw new Error("BakedMsdfRasterizer.load() must resolve before glyphs are requested");
83
+ const baked = bakedGlyph(character);
84
+ if (baked === undefined)
85
+ return undefined;
86
+ const pageSize = BAKED_MSDF_PAGE_SIZE;
87
+ const left = Math.round(baked[0] * pageSize);
88
+ const top = Math.round(baked[1] * pageSize);
89
+ // `ceil`, not `round`: the atlas maps the ink box exactly (`inkWidth` below), so the texels it
90
+ // stores have to be at least as large as that box. Rounding down lets the UV rectangle read past
91
+ // the columns this slice wrote, which pulls the glyph's edge inwards.
92
+ const width = Math.ceil((baked[2] - baked[0]) * pageSize);
93
+ const height = Math.ceil((baked[3] - baked[1]) * pageSize);
94
+ // Whitespace is in the table for its advance, but there is no ink box to slice out of the page.
95
+ if (width <= 0 || height <= 0)
96
+ return undefined;
97
+ const texels = new Uint8Array(width * height * 4);
98
+ for (let row = 0; row < height; row++) {
99
+ const from = ((top + row) * pageSize + left) * 4;
100
+ texels.set(page.subarray(from, from + width * 4), row * width * 4);
101
+ }
102
+ // The texels stay at their baked size; only the placement scales with the requested font size.
103
+ const scale = fontSize / BAKED_MSDF_BAKE_SIZE;
104
+ const metrics = this.glyphMetrics(character, face, fontSize);
105
+ return {
106
+ width,
107
+ height,
108
+ texels,
109
+ // The bake carries the ink box at the bake size, which is what the atlas has to map.
110
+ inkWidth: baked[6],
111
+ inkHeight: baked[7],
112
+ bearingLeft: metrics.bearingLeft,
113
+ bearingTop: metrics.bearingTop,
114
+ advance: metrics.advance,
115
+ };
116
+ }
117
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Outline-backed glyph source, using `opentype.js` to read the bundled TrueType face.
3
+ *
4
+ * Canvas2D can only hand back a coverage mask, and a mask has lost the edges that a multi-channel
5
+ * distance field is built from. Outlines are where those edges still exist, so this source parses
6
+ * the inlined subset and encodes each glyph with `msdfFromOutline`.
7
+ *
8
+ * It is the on-demand counterpart to a prebaked atlas: it works for any character the face covers
9
+ * and any face at all, at the cost of running the encoder on the first frame each glyph appears.
10
+ * Coordinates follow the Canvas2D convention the rest of the text stack uses — x to the right, y
11
+ * down, the pen at (0, 0) and the baseline at y = 0 — which is also what `glyph.getPath` produces.
12
+ */
13
+ import type { Font } from "opentype.js";
14
+ import { type GlyphRasterizer, type RasterizedGlyph } from "./text-font.js";
15
+ import { type OutlineSegment } from "./text-msdf.js";
16
+ import type { FontFace, GlyphMetrics, TextMeasurer, VerticalMetrics } from "./text-layout.js";
17
+ /** The subset of `opentype.js`'s path commands this source understands. */
18
+ type OutlineCommand = {
19
+ readonly type: "M";
20
+ readonly x: number;
21
+ readonly y: number;
22
+ } | {
23
+ readonly type: "L";
24
+ readonly x: number;
25
+ readonly y: number;
26
+ } | {
27
+ readonly type: "Q";
28
+ readonly x1: number;
29
+ readonly y1: number;
30
+ readonly x: number;
31
+ readonly y: number;
32
+ } | {
33
+ readonly type: "C";
34
+ readonly x1: number;
35
+ readonly y1: number;
36
+ readonly x2: number;
37
+ readonly y2: number;
38
+ readonly x: number;
39
+ readonly y: number;
40
+ } | {
41
+ readonly type: "Z";
42
+ };
43
+ export type OutlineGlyphRasterizerOptions = {
44
+ /** TrueType bytes to read. Defaults to the inlined subset. */
45
+ readonly font?: ArrayBuffer;
46
+ /** Distance in texels either side of the edge that the encoded field covers. */
47
+ readonly distanceRange?: number;
48
+ /**
49
+ * Default rasterisation scale: a request that names no scale of its own is encoded at
50
+ * `fontSize * rasterScale` texels while layout and quads stay in font pixels, exactly as the
51
+ * Canvas2D sources do it. The renderer overrides it per block with the size the block covers on
52
+ * screen (see `text-raster`), so the atlas asks for the finer size and the same outline is encoded
53
+ * at a raster that matches it.
54
+ */
55
+ readonly rasterScale?: number;
56
+ /**
57
+ * Whether the renderer drives this source's raster size from the size a block covers on screen.
58
+ * Outlines encode at any size, so it defaults to `true`; a fixed raster is only for a comparison
59
+ * that has to stay at one raster size on purpose.
60
+ */
61
+ readonly dynamicRaster?: boolean;
62
+ };
63
+ /** Turns a path's command list into outline segments, offset so the ink box starts at the origin. */
64
+ export declare function commandsToSegments(commands: readonly OutlineCommand[], offsetX: number, offsetY: number): readonly OutlineSegment[];
65
+ export declare class OutlineGlyphRasterizer implements GlyphRasterizer, TextMeasurer {
66
+ #private;
67
+ readonly encoding: "msdf";
68
+ readonly distanceRange: number;
69
+ readonly rasterScale: number;
70
+ /** Whether the renderer drives this source's raster per block. See `dynamicRaster` in the options. */
71
+ readonly dynamicRaster: boolean;
72
+ constructor(options?: OutlineGlyphRasterizerOptions);
73
+ /** The face this source reads, for callers that want to name it. */
74
+ get font(): Font;
75
+ /**
76
+ * This source holds one parsed buffer, so a weight, width or slant it was not built with cannot
77
+ * change the outlines it returns. Keying on the family alone keeps the atlas from caching the same
78
+ * glyph once per axis value.
79
+ */
80
+ readonly faceKey: (face: FontFace) => string;
81
+ verticalMetrics(_face: FontFace, fontSize: number): VerticalMetrics;
82
+ glyphMetrics(character: string, _face: FontFace, fontSize: number): GlyphMetrics;
83
+ rasterize(character: string, _face: FontFace, rasterSize: number): RasterizedGlyph | undefined;
84
+ }
85
+ export {};