vgpu-render 0.1.0 → 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 (42) hide show
  1. package/README.md +76 -26
  2. package/dist/curve.d.ts +1 -1
  3. package/dist/debug-draw-3d.d.ts +3 -3
  4. package/dist/debug-draw-3d.js +10 -10
  5. package/dist/{frame-recorder-2d.d.ts → frame-draw-list-2d.d.ts} +11 -11
  6. package/dist/{frame-recorder-2d.js → frame-draw-list-2d.js} +6 -6
  7. package/dist/{frame-recorder.d.ts → frame-draw-list.d.ts} +60 -8
  8. package/dist/{frame-recorder.js → frame-draw-list.js} +51 -10
  9. package/dist/geometry-generators.d.ts +2 -2
  10. package/dist/geometry-generators.js +1 -1
  11. package/dist/gpu-debug-draw-2d.d.ts +1 -1
  12. package/dist/gpu-debug-draw-3d.d.ts +1 -1
  13. package/dist/gpu-debug-draw-3d.js +4 -2
  14. package/dist/gpu-text-3d.d.ts +1 -1
  15. package/dist/gpu-text-3d.js +49 -2
  16. package/dist/index.d.ts +3 -3
  17. package/dist/index.js +2 -3
  18. package/dist/mesh-batch-3d.d.ts +1 -1
  19. package/dist/shape2d-shader.js +3 -2
  20. package/dist/text-atlas.d.ts +6 -4
  21. package/dist/text-atlas.js +9 -6
  22. package/dist/text-font.d.ts +48 -7
  23. package/dist/text-font.js +63 -11
  24. package/dist/text-layout.d.ts +63 -4
  25. package/dist/text-layout.js +58 -8
  26. package/dist/text-msdf-baked.d.ts +9 -4
  27. package/dist/text-msdf-baked.js +9 -4
  28. package/dist/text-msdf-font.d.ts +10 -4
  29. package/dist/text-msdf-font.js +9 -3
  30. package/dist/vector-math.d.ts +19 -0
  31. package/dist/vector-math.js +37 -0
  32. package/package.json +1 -1
  33. package/dist/layout-3d.d.ts +0 -227
  34. package/dist/layout-3d.js +0 -226
  35. package/dist/scene-examples.d.ts +0 -39
  36. package/dist/scene-examples.js +0 -200
  37. package/dist/scenes-2d.d.ts +0 -18
  38. package/dist/scenes-2d.js +0 -160
  39. package/dist/scenes-3d.d.ts +0 -46
  40. package/dist/scenes-3d.js +0 -1994
  41. package/dist/text-scenes.d.ts +0 -85
  42. package/dist/text-scenes.js +0 -338
package/dist/text-font.js CHANGED
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { FIRA_MONO_MEDIUM_SUBSET_BASE64, FIRA_MONO_MEDIUM_SUBSET_TTF_BASE64 } from "./assets/fira-mono-medium-subset.js";
17
17
  import { coverageToSdf, DEFAULT_SDF_RANGE } from "./text-sdf.js";
18
+ import { fontFaceKey } from "./text-layout.js";
18
19
  /**
19
20
  * Family name the bundled font is registered under. It is deliberately not `"Fira Mono"` so
20
21
  * a locally installed face can never shadow the inlined one.
@@ -36,6 +37,38 @@ export function decodeBase64Bytes(base64) {
36
37
  bytes[index] = binary.charCodeAt(index);
37
38
  return bytes;
38
39
  }
40
+ const registeredFamilies = new Map();
41
+ /**
42
+ * Registers a family whose weights are separate faces, each carrying its own `weight` descriptor.
43
+ *
44
+ * This is the only way to get more than one drawn weight out of Canvas2D: the font shorthand carries a
45
+ * single synthesised bold step, and `font-variation-settings` does not exist there, but the browser
46
+ * does select a face by the weight it was registered with. Registering the same family once per
47
+ * weight therefore turns `weight: 700` into a real bold face instead of an emboldened regular one.
48
+ *
49
+ * Idempotent per family, like `loadDefaultFont`, and browser-only.
50
+ */
51
+ export function loadFontFaces(family, faces) {
52
+ const existing = registeredFamilies.get(family);
53
+ if (existing !== undefined)
54
+ return existing;
55
+ const registration = registerFontFaces(family, faces);
56
+ registeredFamilies.set(family, registration);
57
+ return registration;
58
+ }
59
+ async function registerFontFaces(family, faces) {
60
+ const fonts = typeof document === "undefined" ? undefined : document.fonts;
61
+ if (fonts === undefined || typeof FontFace === "undefined") {
62
+ throw new Error("registering a font family requires a browser environment");
63
+ }
64
+ const loaded = faces.map(async (face) => {
65
+ const definition = new FontFace(family, decodeBase64Bytes(face.woff2Base64), { weight: String(face.weight) });
66
+ await definition.load();
67
+ return definition;
68
+ });
69
+ for (const definition of await Promise.all(loaded))
70
+ fonts.add(definition);
71
+ }
39
72
  /** Decodes the inlined WOFF2 bytes of the bundled font, which is what `FontFace` takes. */
40
73
  export function decodeEmbeddedFont() {
41
74
  return decodeBase64Bytes(FIRA_MONO_MEDIUM_SUBSET_BASE64);
@@ -66,6 +99,25 @@ async function registerDefaultFont() {
66
99
  await face.load();
67
100
  fonts.add(face);
68
101
  }
102
+ /**
103
+ * The CSS font shorthand that selects `face` at `fontSize`.
104
+ *
105
+ * Order follows the shorthand's grammar — style, weight, stretch, size, family — because Canvas2D
106
+ * parses the value as one. Two of the axes are conditional:
107
+ *
108
+ * - `font-stretch` keywords are only kept by the parser when the face has a `wdth` axis. A static
109
+ * face drops the keyword and draws every stop identically, which is why the bundled subset cannot
110
+ * show a `FontWidth` difference at all.
111
+ * - `italic` is the only slant Canvas2D keeps; `oblique <angle>` reads back as plain `italic`.
112
+ *
113
+ * A weight the loaded face does not carry is synthesised by the browser (one emboldened step, which
114
+ * keeps the advance), so `weight` at least changes what is drawn for the bundled static face.
115
+ */
116
+ export function canvasFontString(face, fontSize) {
117
+ const style = face.style === "italic" ? "italic " : "";
118
+ const stretch = face.width === "normal" ? "" : `${face.width} `;
119
+ return `${style}${face.weight} ${stretch}${fontSize}px "${face.family}"`;
120
+ }
69
121
  /**
70
122
  * Canvas2D glyph source, doubling as the layout `TextMeasurer` so the canvas is only set up
71
123
  * once per canvas-configured face and every `measureText` result is cached.
@@ -109,16 +161,16 @@ export class CanvasGlyphRasterizer {
109
161
  }
110
162
  return this.#surface;
111
163
  }
112
- #font(fontFamily, fontSize) {
113
- return `${fontSize}px "${fontFamily}"`;
164
+ #font(face, fontSize) {
165
+ return canvasFontString(face, fontSize);
114
166
  }
115
- verticalMetrics(fontFamily, fontSize) {
116
- const key = `${fontFamily}|${fontSize}`;
167
+ verticalMetrics(face, fontSize) {
168
+ const key = `${fontFaceKey(face)}|${fontSize}`;
117
169
  const cached = this.#vertical.get(key);
118
170
  if (cached !== undefined)
119
171
  return cached;
120
172
  const context = this.#ensureSurface().context;
121
- context.font = this.#font(fontFamily, fontSize);
173
+ context.font = this.#font(face, fontSize);
122
174
  const metrics = context.measureText("M");
123
175
  const ascent = metrics.fontBoundingBoxAscent > 0 ? metrics.fontBoundingBoxAscent : fontSize * FALLBACK_ASCENT_RATIO;
124
176
  const descent = metrics.fontBoundingBoxDescent > 0 ? metrics.fontBoundingBoxDescent : fontSize * FALLBACK_DESCENT_RATIO;
@@ -126,13 +178,13 @@ export class CanvasGlyphRasterizer {
126
178
  this.#vertical.set(key, value);
127
179
  return value;
128
180
  }
129
- glyphMetrics(character, fontFamily, fontSize) {
130
- const key = `${fontFamily}|${fontSize}|${character}`;
181
+ glyphMetrics(character, face, fontSize) {
182
+ const key = `${fontFaceKey(face)}|${fontSize}|${character}`;
131
183
  const cached = this.#metrics.get(key);
132
184
  if (cached !== undefined)
133
185
  return cached;
134
186
  const context = this.#ensureSurface().context;
135
- context.font = this.#font(fontFamily, fontSize);
187
+ context.font = this.#font(face, fontSize);
136
188
  const metrics = context.measureText(character);
137
189
  const bearingLeft = -metrics.actualBoundingBoxLeft;
138
190
  const value = {
@@ -145,10 +197,10 @@ export class CanvasGlyphRasterizer {
145
197
  this.#metrics.set(key, value);
146
198
  return value;
147
199
  }
148
- rasterize(character, fontFamily, rasterSize) {
200
+ rasterize(character, face, rasterSize) {
149
201
  // The raster size *is* the face size here: `measureText` and the fill both work in the texels the
150
202
  // atlas will store, and the layout's own measurements never pass through this path.
151
- const glyph = this.glyphMetrics(character, fontFamily, rasterSize);
203
+ const glyph = this.glyphMetrics(character, face, rasterSize);
152
204
  if (!(glyph.width > 0) || !(glyph.height > 0))
153
205
  return undefined;
154
206
  // The ink box is fractional; the mask rounds up so no edge pixel is lost.
@@ -160,7 +212,7 @@ export class CanvasGlyphRasterizer {
160
212
  canvas.height = Math.max(canvas.height, height);
161
213
  }
162
214
  context.clearRect(0, 0, width, height);
163
- context.font = this.#font(fontFamily, rasterSize);
215
+ context.font = this.#font(face, rasterSize);
164
216
  context.textAlign = "left";
165
217
  context.textBaseline = "alphabetic";
166
218
  context.fillStyle = "#fff";
@@ -40,12 +40,62 @@ export type GlyphMetrics = {
40
40
  /** Ink box top edge relative to the baseline, y down: positive is below the baseline. */
41
41
  readonly bearingTop: number;
42
42
  };
43
+ /**
44
+ * reference `FontStyle`, minus `Oblique(angle)`.
45
+ *
46
+ * Canvas2D accepts the `italic` keyword in its font shorthand and silently drops the slant angle —
47
+ * `oblique 14deg` reads back as `italic` — so an oblique angle could not be honoured and is left out
48
+ * rather than accepted and ignored. A face without an italic face is slanted by the browser.
49
+ */
50
+ export type TextFontStyle = "normal" | "italic";
51
+ /**
52
+ * reference `FontWidth` stops, spelled as the CSS `font-stretch` keywords Canvas2D understands.
53
+ *
54
+ * reference takes a percentage (`FontWidth::SEMI_CONDENSED` is 87.5), but Canvas2D rejects a percentage in
55
+ * the font shorthand and only accepts these nine keywords. The axis is inert unless the face actually
56
+ * carries a `wdth` axis: a static face like the bundled subset keeps the keyword out of its resolved
57
+ * font, so every stop draws the same width there.
58
+ */
59
+ export type TextFontWidth = "ultra-condensed" | "extra-condensed" | "condensed" | "semi-condensed" | "normal" | "semi-expanded" | "expanded" | "extra-expanded" | "ultra-expanded";
60
+ /** Every `FontWidth` stop, in reference order. */
61
+ export declare const TEXT_FONT_WIDTHS: readonly TextFontWidth[];
62
+ /** Every `FontStyle` variant this implementation can honour. */
63
+ export declare const TEXT_FONT_STYLES: readonly TextFontStyle[];
64
+ /** reference `FontWeight::NORMAL`, and the value the CSS font shorthand defaults to. */
65
+ export declare const DEFAULT_FONT_WEIGHT = 400;
66
+ /** reference `FontWeight` range, 1 to 1000. */
67
+ export declare const MIN_FONT_WEIGHT = 1;
68
+ export declare const MAX_FONT_WEIGHT = 1000;
69
+ /**
70
+ * One face request: everything that decides which glyphs a character rasterises to.
71
+ *
72
+ * This is the unit the glyph atlas keys on, so two blocks that differ only in weight cannot share a
73
+ * rasterised glyph. A source with a single fixed face reports the parts it cannot change through
74
+ * `GlyphRasterizer.faceKey`.
75
+ */
76
+ export type FontFace = {
77
+ readonly family: string;
78
+ readonly weight: number;
79
+ readonly style: TextFontStyle;
80
+ readonly width: TextFontWidth;
81
+ };
82
+ /** A face with the axes left optional, which is how callers and spans express one. */
83
+ export type FontFaceOptions = {
84
+ readonly family: string;
85
+ readonly weight?: number | undefined;
86
+ readonly style?: TextFontStyle | undefined;
87
+ readonly width?: TextFontWidth | undefined;
88
+ };
89
+ /** Folds a face's optional axes into the complete request the measurer and the atlas take. */
90
+ export declare function resolveFontFace(options: FontFaceOptions): FontFace;
91
+ /** Every field that can change which glyphs are rasterised, joined into one cache key. */
92
+ export declare function fontFaceKey(face: FontFace): string;
43
93
  /** Source of glyph metrics. The browser implementation measures through Canvas2D. */
44
94
  export interface TextMeasurer {
45
- /** Vertical metrics for a `fontSize`-sized face of `fontFamily`. */
46
- verticalMetrics(fontFamily: string, fontSize: number): VerticalMetrics;
95
+ /** Vertical metrics for a `fontSize`-sized instance of `face`. */
96
+ verticalMetrics(face: FontFace, fontSize: number): VerticalMetrics;
47
97
  /** Metrics for one character. Whitespace returns a zero-size ink box. */
48
- glyphMetrics(character: string, fontFamily: string, fontSize: number): GlyphMetrics;
98
+ glyphMetrics(character: string, face: FontFace, fontSize: number): GlyphMetrics;
49
99
  }
50
100
  /** Reference `Justify`. `start`/`end` resolve to left/right in this LTR-only layout. */
51
101
  export type TextJustify = "left" | "center" | "right" | "justified" | "start" | "end";
@@ -62,6 +112,9 @@ export type TextSpanInput = {
62
112
  readonly color?: Rgba;
63
113
  readonly fontSize?: number;
64
114
  readonly fontFamily?: string;
115
+ readonly weight?: number;
116
+ readonly style?: TextFontStyle;
117
+ readonly width?: TextFontWidth;
65
118
  };
66
119
  /** reference `LineHeight` default, `RelativeToFont(1.2)`. */
67
120
  export declare const DEFAULT_LINE_HEIGHT_SCALE = 1.2;
@@ -69,6 +122,12 @@ export type TextLayoutOptions = {
69
122
  readonly color: Rgba;
70
123
  readonly fontSize: number;
71
124
  readonly fontFamily: string;
125
+ /** Block default for `FontFace.weight`; spans override it. */
126
+ readonly weight?: number | undefined;
127
+ /** Block default for `FontFace.style`; spans override it. */
128
+ readonly style?: TextFontStyle | undefined;
129
+ /** Block default for `FontFace.width`; spans override it. */
130
+ readonly width?: TextFontWidth | undefined;
72
131
  /**
73
132
  * Absolute line height in layout units, reference `LineHeight::Px`. When omitted the line height
74
133
  * is `lineHeightScale` times the line's own largest font size, which is reference
@@ -95,7 +154,7 @@ export type TextLayoutOptions = {
95
154
  /** One laid-out glyph, ready to become an instance once its atlas rectangle is known. */
96
155
  export type PositionedGlyph = {
97
156
  readonly character: string;
98
- readonly fontFamily: string;
157
+ readonly face: FontFace;
99
158
  readonly fontSize: number;
100
159
  readonly color: Rgba;
101
160
  /** Ink box centre relative to the block's anchor point, y down. */
@@ -18,6 +18,38 @@
18
18
  * Layout happens in one local space: origin at the block's anchor point, x to the right and
19
19
  * **y down**, matching Canvas2D. The renderer flips y for spaces where up is +y.
20
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
+ }
21
53
  const TEXT_ANCHORS = {
22
54
  center: [0.5, 0.5],
23
55
  "top-left": [0, 0],
@@ -144,13 +176,20 @@ function measureLine(characters, letterSpacing) {
144
176
  while (end > 0 && content[end - 1].isSpace)
145
177
  end--;
146
178
  let fontSize = 0;
147
- for (const character of content)
148
- fontSize = Math.max(fontSize, character.fontSize);
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
+ }
149
187
  return {
150
188
  characters: content.slice(0, end),
151
189
  advance: runWidth(content.slice(0, end), letterSpacing),
152
190
  fullAdvance: runWidth(content, letterSpacing),
153
191
  fontSize,
192
+ face,
154
193
  isEmpty: content.length === 0,
155
194
  };
156
195
  }
@@ -241,20 +280,31 @@ function countSpaces(characters) {
241
280
  */
242
281
  export function layoutText(spans, options, measurer) {
243
282
  const items = [];
283
+ const blockFace = resolveFontFace({
284
+ family: options.fontFamily,
285
+ weight: options.weight,
286
+ style: options.style,
287
+ width: options.width,
288
+ });
244
289
  for (const span of spans) {
245
290
  const fontSize = span.fontSize ?? options.fontSize;
246
- const fontFamily = span.fontFamily ?? options.fontFamily;
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
+ });
247
297
  const color = span.color ?? options.color;
248
298
  // Iterating the string yields code points, so surrogate pairs stay whole.
249
299
  for (const character of span.text) {
250
300
  if (character === "\n") {
251
- items.push({ character, fontFamily, fontSize, color, advance: 0, width: 0, height: 0, bearingLeft: 0, bearingTop: 0, isSpace: false });
301
+ items.push({ character, face, fontSize, color, advance: 0, width: 0, height: 0, bearingLeft: 0, bearingTop: 0, isSpace: false });
252
302
  continue;
253
303
  }
254
- const metrics = measurer.glyphMetrics(character, fontFamily, fontSize);
304
+ const metrics = measurer.glyphMetrics(character, face, fontSize);
255
305
  items.push({
256
306
  character,
257
- fontFamily,
307
+ face,
258
308
  fontSize,
259
309
  color,
260
310
  advance: metrics.advance,
@@ -273,7 +323,7 @@ export function layoutText(spans, options, measurer) {
273
323
  // instead of being held to the block's default size.
274
324
  const boxes = lines.map((line) => {
275
325
  const fontSize = line.fontSize > 0 ? line.fontSize : options.fontSize;
276
- const base = measurer.verticalMetrics(options.fontFamily, fontSize);
326
+ const base = measurer.verticalMetrics(line.face ?? blockFace, fontSize);
277
327
  const height = options.lineHeight ?? (options.lineHeightScale ?? DEFAULT_LINE_HEIGHT_SCALE) * fontSize;
278
328
  return { base, height, halfLeading: (height - (base.ascent + base.descent)) / 2 };
279
329
  });
@@ -310,7 +360,7 @@ export function layoutText(spans, options, measurer) {
310
360
  if (character.width > 0 && character.height > 0) {
311
361
  glyphs.push({
312
362
  character: character.character,
313
- fontFamily: character.fontFamily,
363
+ face: character.face,
314
364
  fontSize: character.fontSize,
315
365
  color: character.color,
316
366
  center: [offsetX + penX + character.bearingLeft + character.width / 2, offsetY + baseline + character.bearingTop + character.height / 2],
@@ -15,7 +15,7 @@
15
15
  * baking: one set of texels serves every size.
16
16
  */
17
17
  import { type GlyphRasterizer, type RasterizedGlyph } from "./text-font.js";
18
- import type { GlyphMetrics, TextMeasurer, VerticalMetrics } from "./text-layout.js";
18
+ import type { FontFace, GlyphMetrics, TextMeasurer, VerticalMetrics } from "./text-layout.js";
19
19
  export declare class BakedMsdfRasterizer implements GlyphRasterizer, TextMeasurer {
20
20
  #private;
21
21
  readonly encoding: "msdf";
@@ -30,7 +30,12 @@ export declare class BakedMsdfRasterizer implements GlyphRasterizer, TextMeasure
30
30
  get ready(): boolean;
31
31
  /** Decompresses the inlined page. Idempotent, so calling it per frame is harmless. */
32
32
  load(): Promise<void>;
33
- verticalMetrics(_fontFamily: string, fontSize: number): VerticalMetrics;
34
- glyphMetrics(character: string, _fontFamily: string, fontSize: number): GlyphMetrics;
35
- rasterize(character: string, fontFamily: string, fontSize: number): RasterizedGlyph | undefined;
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;
36
41
  }
@@ -48,11 +48,16 @@ export class BakedMsdfRasterizer {
48
48
  }
49
49
  this.#page = page;
50
50
  }
51
- verticalMetrics(_fontFamily, fontSize) {
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) {
52
57
  const scale = fontSize / BAKED_MSDF_BAKE_SIZE;
53
58
  return { ascent: BAKED_MSDF_ASCENT * scale, descent: BAKED_MSDF_DESCENT * scale };
54
59
  }
55
- glyphMetrics(character, _fontFamily, fontSize) {
60
+ glyphMetrics(character, _face, fontSize) {
56
61
  const key = `${fontSize}|${character}`;
57
62
  const cached = this.#metrics.get(key);
58
63
  if (cached !== undefined)
@@ -71,7 +76,7 @@ export class BakedMsdfRasterizer {
71
76
  this.#metrics.set(key, value);
72
77
  return value;
73
78
  }
74
- rasterize(character, fontFamily, fontSize) {
79
+ rasterize(character, face, fontSize) {
75
80
  const page = this.#page;
76
81
  if (page === undefined)
77
82
  throw new Error("BakedMsdfRasterizer.load() must resolve before glyphs are requested");
@@ -96,7 +101,7 @@ export class BakedMsdfRasterizer {
96
101
  }
97
102
  // The texels stay at their baked size; only the placement scales with the requested font size.
98
103
  const scale = fontSize / BAKED_MSDF_BAKE_SIZE;
99
- const metrics = this.glyphMetrics(character, fontFamily, fontSize);
104
+ const metrics = this.glyphMetrics(character, face, fontSize);
100
105
  return {
101
106
  width,
102
107
  height,
@@ -13,7 +13,7 @@
13
13
  import type { Font } from "opentype.js";
14
14
  import { type GlyphRasterizer, type RasterizedGlyph } from "./text-font.js";
15
15
  import { type OutlineSegment } from "./text-msdf.js";
16
- import type { GlyphMetrics, TextMeasurer, VerticalMetrics } from "./text-layout.js";
16
+ import type { FontFace, GlyphMetrics, TextMeasurer, VerticalMetrics } from "./text-layout.js";
17
17
  /** The subset of `opentype.js`'s path commands this source understands. */
18
18
  type OutlineCommand = {
19
19
  readonly type: "M";
@@ -72,8 +72,14 @@ export declare class OutlineGlyphRasterizer implements GlyphRasterizer, TextMeas
72
72
  constructor(options?: OutlineGlyphRasterizerOptions);
73
73
  /** The face this source reads, for callers that want to name it. */
74
74
  get font(): Font;
75
- verticalMetrics(_fontFamily: string, fontSize: number): VerticalMetrics;
76
- glyphMetrics(character: string, _fontFamily: string, fontSize: number): GlyphMetrics;
77
- rasterize(character: string, _fontFamily: string, rasterSize: number): RasterizedGlyph | undefined;
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;
78
84
  }
79
85
  export {};
@@ -105,7 +105,13 @@ export class OutlineGlyphRasterizer {
105
105
  const bottom = Number.isFinite(box.y2) ? box.y2 : top;
106
106
  return { left, top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) };
107
107
  }
108
- verticalMetrics(_fontFamily, fontSize) {
108
+ /**
109
+ * This source holds one parsed buffer, so a weight, width or slant it was not built with cannot
110
+ * change the outlines it returns. Keying on the family alone keeps the atlas from caching the same
111
+ * glyph once per axis value.
112
+ */
113
+ faceKey = (face) => face.family;
114
+ verticalMetrics(_face, fontSize) {
109
115
  const cached = this.#vertical.get(String(fontSize));
110
116
  if (cached !== undefined)
111
117
  return cached;
@@ -114,7 +120,7 @@ export class OutlineGlyphRasterizer {
114
120
  this.#vertical.set(String(fontSize), value);
115
121
  return value;
116
122
  }
117
- glyphMetrics(character, _fontFamily, fontSize) {
123
+ glyphMetrics(character, _face, fontSize) {
118
124
  const key = `${fontSize}|${character}`;
119
125
  const cached = this.#metrics.get(key);
120
126
  if (cached !== undefined)
@@ -131,7 +137,7 @@ export class OutlineGlyphRasterizer {
131
137
  this.#metrics.set(key, value);
132
138
  return value;
133
139
  }
134
- rasterize(character, _fontFamily, rasterSize) {
140
+ rasterize(character, _face, rasterSize) {
135
141
  const ink = this.#inkBox(character, rasterSize);
136
142
  if (!(ink.width > 0) || !(ink.height > 0))
137
143
  return undefined;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Small vector helpers shared by the geometry generators and the example layouts.
3
+ *
4
+ * These live in the library rather than next to the gallery scenes because
5
+ * `geometry-generators.ts` needs them, and a library module must not reach into
6
+ * the example code.
7
+ */
8
+ import type { Vec3 } from "./frame-draw-list.js";
9
+ export declare function normalizeVector(p: Vec3): Vec3;
10
+ export declare function crossVector(a: Vec3, b: Vec3): Vec3;
11
+ /** Right-handed Rodrigues rotation, equivalent to reference `Vector3.rotated(axis, angle)`. */
12
+ export declare function rotateAroundAxis(p: Vec3, axis: Vec3, angle: number): Vec3;
13
+ export type CircleBasis = {
14
+ readonly x: Vec3;
15
+ readonly y: Vec3;
16
+ readonly z: Vec3;
17
+ };
18
+ /** Orthonormal basis for a circle in the plane whose normal is `normal`. */
19
+ export declare function circleBasis(normal: Vec3, angle: number): CircleBasis;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Small vector helpers shared by the geometry generators and the example layouts.
3
+ *
4
+ * These live in the library rather than next to the gallery scenes because
5
+ * `geometry-generators.ts` needs them, and a library module must not reach into
6
+ * the example code.
7
+ */
8
+ export function normalizeVector(p) {
9
+ const length = Math.hypot(p[0], p[1], p[2]) || 1;
10
+ return [p[0] / length, p[1] / length, p[2] / length];
11
+ }
12
+ export function crossVector(a, b) {
13
+ return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
14
+ }
15
+ /** Right-handed Rodrigues rotation, equivalent to reference `Vector3.rotated(axis, angle)`. */
16
+ export function rotateAroundAxis(p, axis, angle) {
17
+ const a = normalizeVector(axis);
18
+ const cos = Math.cos(angle);
19
+ const sin = Math.sin(angle);
20
+ const dot = p[0] * a[0] + p[1] * a[1] + p[2] * a[2];
21
+ const cross = crossVector(a, p);
22
+ return [
23
+ p[0] * cos + cross[0] * sin + a[0] * dot * (1 - cos),
24
+ p[1] * cos + cross[1] * sin + a[1] * dot * (1 - cos),
25
+ p[2] * cos + cross[2] * sin + a[2] * dot * (1 - cos),
26
+ ];
27
+ }
28
+ /** Orthonormal basis for a circle in the plane whose normal is `normal`. */
29
+ export function circleBasis(normal, angle) {
30
+ const n = normalizeVector(normal);
31
+ const reference = Math.abs(n[0]) > 0.95 ? [0, 0, 1] : [1, 0, 0];
32
+ const projection = reference[0] * n[0] + reference[1] * n[1] + reference[2] * n[2];
33
+ const planar = [reference[0] - n[0] * projection, reference[1] - n[1] * projection, reference[2] - n[2] * projection];
34
+ const widthAxis = normalizeVector(rotateAroundAxis(normalizeVector(planar), n, angle));
35
+ const heightAxis = normalizeVector(crossVector(n, widthAxis));
36
+ return { x: widthAxis, y: heightAxis, z: n };
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vgpu-render",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "WebGPU debug-draw, gizmo, infinite grid and text layer built on vgpu",
5
5
  "license": "MIT",
6
6
  "type": "module",