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
@@ -1,14 +1,16 @@
1
1
  import { sampleCurve } from "./curve.js";
2
- import { FrameRecorder2D } from "./frame-recorder-2d.js";
2
+ import { FrameDrawList2D } from "./frame-draw-list-2d.js";
3
3
  import { gridLines } from "./geometry-generators.js";
4
4
  import { DEFAULT_FONT_FAMILY } from "./text-font.js";
5
- import { resolveTextAnchor, DEFAULT_LINE_HEIGHT_SCALE } from "./text-layout.js";
5
+ import { resolveTextAnchor, DEFAULT_LINE_HEIGHT_SCALE, DEFAULT_FONT_WEIGHT, MAX_FONT_WEIGHT, MIN_FONT_WEIGHT, TEXT_FONT_STYLES, TEXT_FONT_WIDTHS, } from "./text-layout.js";
6
6
  export const DEFAULT_COLOR = [1, 1, 1, 1];
7
7
  export const DEFAULT_LAYER = 1;
8
8
  /** Default block font size in pixels; the world spaces scale it by `DEFAULT_TEXT_PIXEL_SIZE`. */
9
9
  export const DEFAULT_TEXT_SIZE = 16;
10
10
  /** Default world units per font pixel, i.e. a 0.32 world-unit glyph at the default size. */
11
11
  export const DEFAULT_TEXT_PIXEL_SIZE = 0.02;
12
+ /** `world-plane` blocks face +Z until a rotation lays them down, like a reference `Label3D`. */
13
+ export const DEFAULT_TEXT_ROTATION = [0, 0, 0];
12
14
  /** reference `LineHeight` default is `RelativeToFont(1.2)`. */
13
15
  export const DEFAULT_LINE_HEIGHT_RATIO = DEFAULT_LINE_HEIGHT_SCALE;
14
16
  function finite(value, name) {
@@ -79,6 +81,24 @@ function textSpaceValue(value, name) {
79
81
  }
80
82
  return value;
81
83
  }
84
+ function fontWeightValue(value, name) {
85
+ if (!Number.isFinite(value) || value < MIN_FONT_WEIGHT || value > MAX_FONT_WEIGHT) {
86
+ throw new RangeError(`${name} must be between ${MIN_FONT_WEIGHT} and ${MAX_FONT_WEIGHT}`);
87
+ }
88
+ return value;
89
+ }
90
+ function textFontStyleValue(value, name) {
91
+ if (!TEXT_FONT_STYLES.includes(value)) {
92
+ throw new RangeError(`${name} must be one of ${TEXT_FONT_STYLES.join(", ")}`);
93
+ }
94
+ return value;
95
+ }
96
+ function textFontWidthValue(value, name) {
97
+ if (!TEXT_FONT_WIDTHS.includes(value)) {
98
+ throw new RangeError(`${name} must be one of ${TEXT_FONT_WIDTHS.join(", ")}`);
99
+ }
100
+ return value;
101
+ }
82
102
  function textBounds(value, name) {
83
103
  if (value.length !== 2)
84
104
  throw new RangeError(`${name} must contain two values`);
@@ -90,7 +110,7 @@ function textBounds(value, name) {
90
110
  * Folds the block defaults into every span so the renderer resolves a style once per span and
91
111
  * never has to guess what an omitted field meant.
92
112
  */
93
- function resolveTextSpans(text, options, defaults, baseFontSize, baseFontFamily) {
113
+ function resolveTextSpans(text, options, defaults, baseFontSize, baseFontFamily, baseWeight, baseStyle, baseWidth) {
94
114
  const input = typeof text === "string" ? [{ text }] : text;
95
115
  const baseColor = color(options.color ?? defaults.color);
96
116
  return input.map((span) => {
@@ -102,11 +122,15 @@ function resolveTextSpans(text, options, defaults, baseFontSize, baseFontFamily)
102
122
  color: span.color === undefined ? baseColor : color(span.color),
103
123
  fontSize,
104
124
  fontFamily: span.fontFamily ?? baseFontFamily,
125
+ // A span overrides the block's face one axis at a time, like a nested `TextFont` component.
126
+ weight: span.weight === undefined ? baseWeight : fontWeightValue(span.weight, "span weight"),
127
+ style: span.style === undefined ? baseStyle : textFontStyleValue(span.style, "span style"),
128
+ width: span.width === undefined ? baseWidth : textFontWidthValue(span.width, "span width"),
105
129
  };
106
130
  });
107
131
  }
108
132
  /** Validates and resolves style defaults so invalid values fail at construction time. */
109
- export function resolveFrameRecorderDefaults(defaults = {}) {
133
+ export function resolveFrameDrawListDefaults(defaults = {}) {
110
134
  const point = defaults.point ?? {};
111
135
  const line = defaults.line ?? {};
112
136
  const text = defaults.text ?? {};
@@ -168,6 +192,9 @@ export function resolveFrameRecorderDefaults(defaults = {}) {
168
192
  fontSize: textSize,
169
193
  pixelSize: textPixelSize,
170
194
  fontFamily: text.fontFamily,
195
+ weight: text.weight === undefined ? undefined : fontWeightValue(text.weight, "default text weight"),
196
+ style: text.style === undefined ? undefined : textFontStyleValue(text.style, "default text style"),
197
+ width: text.width === undefined ? undefined : textFontWidthValue(text.width, "default text width"),
171
198
  lineHeight: textLineHeight,
172
199
  lineHeightScale: textLineHeightScale,
173
200
  letterSpacing: text.letterSpacing === undefined ? undefined : finite(text.letterSpacing, "default text letterSpacing"),
@@ -175,13 +202,19 @@ export function resolveFrameRecorderDefaults(defaults = {}) {
175
202
  lineBreak: text.lineBreak === undefined ? undefined : textLineBreakValue(text.lineBreak, "default text lineBreak"),
176
203
  anchor: textAnchor,
177
204
  space: text.space === undefined ? undefined : textSpaceValue(text.space, "default text space"),
205
+ rotation: text.rotation === undefined ? undefined : vec3(text.rotation, "default text rotation"),
178
206
  source: text.source,
179
207
  depthTest: text.depthTest,
180
208
  layer: text.layer === undefined ? undefined : layerValue(text.layer, "default text layer"),
181
209
  },
182
210
  };
183
211
  }
184
- export class FrameRecorder {
212
+ /**
213
+ * One frame's draw list: records primitives, then hands them to `GpuDebugDraw3D` as
214
+ * `FrameCommand[]`. This is the `ImDrawList` / `GizmoBuffer` layer; GPU command encoding
215
+ * happens later in the renderer.
216
+ */
217
+ export class FrameDrawList {
185
218
  #points = [];
186
219
  #lines = [];
187
220
  #joints = [];
@@ -191,10 +224,10 @@ export class FrameRecorder {
191
224
  #d2;
192
225
  #sealed = false;
193
226
  constructor(defaults = {}) {
194
- this.#defaults = resolveFrameRecorderDefaults(defaults);
195
- // The 2D layer shares this recorder's resolved point/line styles, so a style set on
227
+ this.#defaults = resolveFrameDrawListDefaults(defaults);
228
+ // The 2D layer shares this drawList's resolved point/line styles, so a style set on
196
229
  // `DebugDraw3D` cannot apply to one layer and not the other.
197
- this.#d2 = new FrameRecorder2D({ point: this.#defaults.point, line: this.#defaults.line }, () => this.assertOpen());
230
+ this.#d2 = new FrameDrawList2D({ point: this.#defaults.point, line: this.#defaults.line }, () => this.assertOpen());
198
231
  }
199
232
  /**
200
233
  * The 2D layer: `Vec2` primitives on the reference-style `Camera2d` spaces described in
@@ -431,10 +464,14 @@ export class FrameRecorder {
431
464
  throw new RangeError("lineHeight must be greater than zero");
432
465
  const letterSpacing = finite(options.letterSpacing ?? defaults.letterSpacing ?? 0, "letterSpacing");
433
466
  const fontFamily = options.fontFamily ?? defaults.fontFamily ?? DEFAULT_FONT_FAMILY;
467
+ const weight = fontWeightValue(options.weight ?? defaults.weight ?? DEFAULT_FONT_WEIGHT, "weight");
468
+ const style = textFontStyleValue(options.style ?? defaults.style ?? "normal", "style");
469
+ const width = textFontWidthValue(options.width ?? defaults.width ?? "normal", "width");
434
470
  const justify = options.justify ?? defaults.justify ?? "left";
435
471
  const lineBreak = options.lineBreak ?? defaults.lineBreak ?? "word";
436
472
  const anchor = options.anchor ?? defaults.anchor ?? "center";
437
473
  const space = options.space ?? defaults.space ?? "world-plane";
474
+ const rotation = vec3(options.rotation ?? defaults.rotation ?? DEFAULT_TEXT_ROTATION, "rotation");
438
475
  const source = options.source ?? defaults.source;
439
476
  const bounds = options.bounds;
440
477
  if (bounds !== undefined)
@@ -451,13 +488,16 @@ export class FrameRecorder {
451
488
  this.#texts.push({
452
489
  kind: "text",
453
490
  position: vec3(position, "position"),
454
- spans: resolveTextSpans(text, options, defaults, fontSize, fontFamily),
491
+ spans: resolveTextSpans(text, options, defaults, fontSize, fontFamily, weight, style, width),
455
492
  color: color(options.color ?? defaults.color),
456
493
  outlineWidth,
457
494
  outlineColor,
458
495
  fontSize,
459
496
  pixelSize,
460
497
  fontFamily,
498
+ weight,
499
+ style,
500
+ width,
461
501
  lineHeight,
462
502
  ...(lineHeightScale === undefined ? {} : { lineHeightScale }),
463
503
  letterSpacing,
@@ -465,6 +505,7 @@ export class FrameRecorder {
465
505
  lineBreak,
466
506
  anchor,
467
507
  space,
508
+ rotation,
468
509
  ...(source === undefined ? {} : { source }),
469
510
  depthTest: options.depthTest ?? defaults.depthTest ?? true,
470
511
  layer: layer(options.layer ?? defaults.layer),
@@ -510,6 +551,6 @@ export class FrameRecorder {
510
551
  }
511
552
  assertOpen() {
512
553
  if (this.#sealed)
513
- throw new Error("frame recorder is sealed");
554
+ throw new Error("frame drawList is sealed");
514
555
  }
515
556
  }
@@ -1,5 +1,5 @@
1
- import type { Vec3 } from "./frame-recorder.js";
2
- import { type CircleBasis } from "./layout-3d.js";
1
+ import type { Vec3 } from "./frame-draw-list.js";
2
+ import { type CircleBasis } from "./vector-math.js";
3
3
  export type Triangle = readonly [Vec3, Vec3, Vec3];
4
4
  export type Segment = readonly [Vec3, Vec3];
5
5
  export type MeshData = {
@@ -1,4 +1,4 @@
1
- import { crossVector, normalizeVector, rotateAroundAxis } from "./layout-3d.js";
1
+ import { crossVector, normalizeVector, rotateAroundAxis } from "./vector-math.js";
2
2
  const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
3
3
  const scale = (a, n) => [a[0] * n, a[1] * n, a[2] * n];
4
4
  const rotate = (p, rotation) => {
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { type FrameComputePass, type FramePass, type Gpu } from "vgpu";
16
16
  import { type Camera2D } from "./camera-2d.js";
17
- import type { FrameCommand2D } from "./frame-recorder-2d.js";
17
+ import type { FrameCommand2D } from "./frame-draw-list-2d.js";
18
18
  export type GpuDebugDraw2DOptions = {
19
19
  /**
20
20
  * WGSL for the shared primitives, generated by `GpuDebugDraw3D` so the two layers cannot drift
@@ -1,6 +1,6 @@
1
1
  import { type Frame, type FramePass, type Gpu } from "vgpu";
2
2
  import type { Camera2D } from "./camera-2d.js";
3
- import type { FrameCommand } from "./frame-recorder.js";
3
+ import type { FrameCommand } from "./frame-draw-list.js";
4
4
  import { type GpuText3DOptions } from "./gpu-text-3d.js";
5
5
  /**
6
6
  * Named glyph sources. A text command picks one by name through `TextOptions.source`; commands that
@@ -710,8 +710,10 @@ export class GpuDebugDraw3D {
710
710
  this.#viewProjection = camera.viewProjection;
711
711
  this.#view = camera.view;
712
712
  // Every renderer that exists is prepared, even with an empty list: that is what drops the
713
- // previous frame's glyphs instead of leaving them on screen.
714
- if (textCommands.length > 0 || this.#texts.size > 0) {
713
+ // previous frame's glyphs instead of leaving them on screen. A caller that never configured a
714
+ // text source asked for geometry only, so the example scenes' own labels are drawn by nobody
715
+ // rather than failing the frame — a named source that is missing still throws.
716
+ if (this.#textOptions !== undefined && (textCommands.length > 0 || this.#texts.size > 0)) {
715
717
  this.#prepareText(textCommands, camera);
716
718
  }
717
719
  // Always prepared, even with an empty list: that is what drops the previous frame's 2D
@@ -32,7 +32,7 @@
32
32
  * glyph's translucent ink box from clipping the glyphs drawn after it.
33
33
  */
34
34
  import { type FrameComputePass, type FramePass, type Gpu } from "vgpu";
35
- import type { TextCommand } from "./frame-recorder.js";
35
+ import type { TextCommand } from "./frame-draw-list.js";
36
36
  import { type TextMeasurer } from "./text-layout.js";
37
37
  import type { GlyphRasterizer } from "./text-font.js";
38
38
  /** Both halves of a font: metrics for layout and pixels for the atlas. */
@@ -205,7 +205,12 @@ struct Out { @builtin(position) clip: vec4f, @location(0) uv: vec2f, @location(1
205
205
  let up = vec3f(camera.view[0][1], camera.view[1][1], camera.view[2][1]);
206
206
  worldPosition = input.origin + right * local.x - up * local.y;
207
207
  } else {
208
- worldPosition = input.origin + vec3f(local.x, -local.y, 0.0);
208
+ // The instance world matrix carries the block's own plane basis: column 0 is the reading
209
+ // direction, column 1 the layout's down. Label3D.rotation lands on that basis, so the default
210
+ // identity keeps the plane facing +Z.
211
+ let axisX = input.world0.xyz;
212
+ let axisY = input.world1.xyz;
213
+ worldPosition = input.origin + axisX * local.x - axisY * local.y;
209
214
  }
210
215
  clip = camera.viewProjection * vec4f(worldPosition, 1.0);
211
216
  }
@@ -233,6 +238,41 @@ fn atlasTexelsPerPixel(uv: vec2f) -> f32 {
233
238
 
234
239
  ${fragment}`;
235
240
  }
241
+ /** `world-plane` blocks keep facing +Z until their own rotation lays them down. */
242
+ const IDENTITY_WORLD = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
243
+ /**
244
+ * The instance world matrix of a `world-plane` block, from the reference `Label3D.rotation` Euler
245
+ * order (Y, then X, then Z). Column 0 is the reading direction and column 1 the layout's down, so a
246
+ * `[-PI/2, 0, 0]` block lies flat on the XZ plane and reads from above.
247
+ *
248
+ * The basis travels in the instance world matrix rather than in two extra instance attributes: the
249
+ * text pipeline already uses the device's full vertex attribute budget.
250
+ */
251
+ function textPlaneWorld(rotation) {
252
+ const [pitch, yaw, roll] = rotation;
253
+ if (pitch === 0 && yaw === 0 && roll === 0)
254
+ return IDENTITY_WORLD;
255
+ const cosRoll = Math.cos(roll);
256
+ const sinRoll = Math.sin(roll);
257
+ const cosPitch = Math.cos(pitch);
258
+ const sinPitch = Math.sin(pitch);
259
+ const cosYaw = Math.cos(yaw);
260
+ const sinYaw = Math.sin(yaw);
261
+ // Rz, then Rx, then Ry: the vector order that matches reference YXZ Euler convention.
262
+ let x = [cosRoll, sinRoll, 0];
263
+ let y = [-sinRoll, cosRoll, 0];
264
+ x = [x[0], x[1] * cosPitch - x[2] * sinPitch, x[1] * sinPitch + x[2] * cosPitch];
265
+ y = [y[0], y[1] * cosPitch - y[2] * sinPitch, y[1] * sinPitch + y[2] * cosPitch];
266
+ x = [x[0] * cosYaw + x[2] * sinYaw, x[1], -x[0] * sinYaw + x[2] * cosYaw];
267
+ y = [y[0] * cosYaw + y[2] * sinYaw, y[1], -y[0] * sinYaw + y[2] * cosYaw];
268
+ const z = [x[1] * y[2] - x[2] * y[1], x[2] * y[0] - x[0] * y[2], x[0] * y[1] - x[1] * y[0]];
269
+ return new Float32Array([
270
+ x[0], x[1], x[2], 0,
271
+ y[0], y[1], y[2], 0,
272
+ z[0], z[1], z[2], 0,
273
+ 0, 0, 0, 1,
274
+ ]);
275
+ }
236
276
  export class GpuText3D {
237
277
  #gpu;
238
278
  #font;
@@ -349,6 +389,9 @@ export class GpuText3D {
349
389
  color: command.color,
350
390
  fontSize: command.fontSize,
351
391
  fontFamily: command.fontFamily,
392
+ weight: command.weight,
393
+ style: command.style,
394
+ width: command.width,
352
395
  // A relative line height stays relative all the way to layout, where each line resolves it
353
396
  // against that line's own largest span; only an explicit one is absolute.
354
397
  ...(command.lineHeightScale === undefined
@@ -361,6 +404,7 @@ export class GpuText3D {
361
404
  anchor: resolveTextAnchor(command.anchor),
362
405
  }, this.#font);
363
406
  const space = command.space === "world-billboard" ? SPACE_WORLD_BILLBOARD : command.space === "screen" ? SPACE_SCREEN : SPACE_WORLD_PLANE;
407
+ const planeWorld = space === SPACE_WORLD_PLANE ? textPlaneWorld(command.rotation) : IDENTITY_WORLD;
364
408
  // Screen text never takes part in depth testing, whatever the command asked for.
365
409
  const flags = space | (command.depthTest && space !== SPACE_SCREEN ? 0 : FLAG_NO_DEPTH);
366
410
  // The quad's size comes from the layout, not from the atlas. A distance field can be drawn at
@@ -382,7 +426,7 @@ export class GpuText3D {
382
426
  const rasterScale = dynamicRaster
383
427
  ? rasterScaleFor(quantizeRasterPx(Math.max(glyph.fontSize * screenPxPerFontPx, glyph.fontSize * rasterFloor)), glyph.fontSize)
384
428
  : undefined;
385
- const entry = this.#atlas.glyph(glyph.character, glyph.fontFamily, glyph.fontSize, rasterScale);
429
+ const entry = this.#atlas.glyph(glyph.character, glyph.face, glyph.fontSize, rasterScale);
386
430
  if (entry === undefined)
387
431
  continue;
388
432
  const id = this.#ids[index] ?? this.#addInstance(index);
@@ -398,6 +442,9 @@ export class GpuText3D {
398
442
  outlineWidth: command.outlineWidth,
399
443
  outlineColor: command.outlineColor,
400
444
  });
445
+ // The matrix always reflects this block's own plane, because instances are reused across
446
+ // blocks and a stale basis would tilt a block that asked for none.
447
+ this.#glyphs.setWorld(id, planeWorld);
401
448
  index++;
402
449
  }
403
450
  }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export * from "./debug-draw-3d.js";
2
2
  export * from "./camera-2d.js";
3
- export * from "./frame-recorder.js";
4
- export * from "./frame-recorder-2d.js";
3
+ export * from "./frame-draw-list.js";
4
+ export * from "./frame-draw-list-2d.js";
5
5
  export * from "./color-space.js";
6
6
  export * from "./curve.js";
7
7
  export * from "./geometry-generators.js";
@@ -11,7 +11,6 @@ export * from "./gpu-text-3d.js";
11
11
  export * from "./infinite-grid-3d.js";
12
12
  export * from "./infinite-grid-2d.js";
13
13
  export * from "./mesh-batch-3d.js";
14
- export * from "./scenes-3d.js";
15
14
  export * from "./text-atlas.js";
16
15
  export * from "./text-font.js";
17
16
  export * from "./text-layout.js";
@@ -19,3 +18,4 @@ export * from "./text-sdf.js";
19
18
  export * from "./text-msdf.js";
20
19
  export * from "./text-msdf-font.js";
21
20
  export * from "./text-msdf-baked.js";
21
+ export type { CircleBasis } from "./vector-math.js";
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export * from "./debug-draw-3d.js";
2
2
  export * from "./camera-2d.js";
3
- export * from "./frame-recorder.js";
4
- export * from "./frame-recorder-2d.js";
3
+ export * from "./frame-draw-list.js";
4
+ export * from "./frame-draw-list-2d.js";
5
5
  export * from "./color-space.js";
6
6
  export * from "./curve.js";
7
7
  export * from "./geometry-generators.js";
@@ -11,7 +11,6 @@ export * from "./gpu-text-3d.js";
11
11
  export * from "./infinite-grid-3d.js";
12
12
  export * from "./infinite-grid-2d.js";
13
13
  export * from "./mesh-batch-3d.js";
14
- export * from "./scenes-3d.js";
15
14
  export * from "./text-atlas.js";
16
15
  export * from "./text-font.js";
17
16
  export * from "./text-layout.js";
@@ -1,6 +1,6 @@
1
1
  import { type FramePass, type Gpu } from "vgpu";
2
2
  import type { MeshData } from "./geometry-generators.js";
3
- import type { Color } from "./frame-recorder.js";
3
+ import type { Color } from "./frame-draw-list.js";
4
4
  export declare class MeshBatch3D {
5
5
  #private;
6
6
  constructor(gpu: Gpu, mesh: MeshData, color: Color, solid: boolean);
@@ -213,8 +213,9 @@ fn shape_distance(shape: u32, p: vec2f, size: vec2f, vertexB: vec2f, vertexC: ve
213
213
  case SHAPE_ROUNDED_RECT: { return sd_rounded_box(p, size, cornerRadius); }
214
214
  case SHAPE_CIRCLE: { return sd_ellipse(p, size); }
215
215
  case SHAPE_ELLIPSE: { return sd_ellipse(p, size); }
216
- // An arc has no interior: its distance is unsigned, so filling one yields a hairline.
217
- case SHAPE_ARC: { return abs(length(p) - size.x); }
216
+ // An arc has no interior: the ring alone ignores the angles, so it is clipped to the swept
217
+ // wedge. Filling one still yields a hairline, because the distance stays unsigned.
218
+ case SHAPE_ARC: { return max(abs(length(p) - size.x), sd_sector(p, size.x, startAngle, sweep)); }
218
219
  case SHAPE_ANNULUS: { return sd_annulus(p, size.y, size.x); }
219
220
  case SHAPE_CAPSULE: { return sd_capsule(p, size.y, size.x); }
220
221
  case SHAPE_POLYGON: { return sd_regular_polygon(p, size.x, sides); }
@@ -9,14 +9,16 @@
9
9
  * 2 px of padding; both choices are kept here, since the padding is what keeps linear
10
10
  * sampling from bleeding between neighbours.
11
11
  *
12
- * Unlike reference the atlas is not keyed per font size: the cache key carries the family and size
13
- * instead, so one page set serves every size a frame happens to use. Glyphs are rasterised at
14
- * `rasterScale` times their font size, which decouples the atlas' detail from the logical layout
12
+ * Unlike reference the atlas is not keyed per font size: the cache key carries the face and size
13
+ * instead, so one page set serves every size a frame happens to use. The face part comes from
14
+ * `GlyphRasterizer.faceKey`, which is how a source states which of the axes it can actually vary.
15
+ * Glyphs are rasterised at `rasterScale` times their font size, which decouples the atlas' detail from the logical layout
15
16
  * size — the same separation as resampling a font at a higher point size before scaling it down.
16
17
  * Pages grow on demand until `maxPages` is reached — the GPU renderer pins that at one page so a
17
18
  * single draw call covers the whole atlas — and a `revision` counter tells the renderer which of
18
19
  * them changed since the last upload.
19
20
  */
21
+ import { type FontFace } from "./text-layout.js";
20
22
  import type { GlyphEncoding, GlyphRasterizer } from "./text-font.js";
21
23
  /** Page size and inter-glyph padding, matching reference minimum atlas and `GLYPH_ATLAS_PADDING`. */
22
24
  export declare const DEFAULT_ATLAS_SIZE = 512;
@@ -111,7 +113,7 @@ export declare class GlyphAtlas {
111
113
  * exactly as it did. The renderer passes the size the block covers on screen instead, so the entry
112
114
  * that comes back is rasterised for the rung that face asked for.
113
115
  */
114
- glyph(character: string, fontFamily: string, fontSize: number, rasterScale?: number): AtlasGlyph | undefined;
116
+ glyph(character: string, face: FontFace, fontSize: number, rasterScale?: number): AtlasGlyph | undefined;
115
117
  /** Drops every cached glyph. The next frame re-rasterises whatever it still draws. */
116
118
  clear(): void;
117
119
  }
@@ -9,14 +9,16 @@
9
9
  * 2 px of padding; both choices are kept here, since the padding is what keeps linear
10
10
  * sampling from bleeding between neighbours.
11
11
  *
12
- * Unlike reference the atlas is not keyed per font size: the cache key carries the family and size
13
- * instead, so one page set serves every size a frame happens to use. Glyphs are rasterised at
14
- * `rasterScale` times their font size, which decouples the atlas' detail from the logical layout
12
+ * Unlike reference the atlas is not keyed per font size: the cache key carries the face and size
13
+ * instead, so one page set serves every size a frame happens to use. The face part comes from
14
+ * `GlyphRasterizer.faceKey`, which is how a source states which of the axes it can actually vary.
15
+ * Glyphs are rasterised at `rasterScale` times their font size, which decouples the atlas' detail from the logical layout
15
16
  * size — the same separation as resampling a font at a higher point size before scaling it down.
16
17
  * Pages grow on demand until `maxPages` is reached — the GPU renderer pins that at one page so a
17
18
  * single draw call covers the whole atlas — and a `revision` counter tells the renderer which of
18
19
  * them changed since the last upload.
19
20
  */
21
+ import { fontFaceKey } from "./text-layout.js";
20
22
  /** Page size and inter-glyph padding, matching reference minimum atlas and `GLYPH_ATLAS_PADDING`. */
21
23
  export const DEFAULT_ATLAS_SIZE = 512;
22
24
  export const DEFAULT_ATLAS_PADDING = 2;
@@ -100,13 +102,14 @@ export class GlyphAtlas {
100
102
  * exactly as it did. The renderer passes the size the block covers on screen instead, so the entry
101
103
  * that comes back is rasterised for the rung that face asked for.
102
104
  */
103
- glyph(character, fontFamily, fontSize, rasterScale) {
105
+ glyph(character, face, fontSize, rasterScale) {
104
106
  const scale = rasterScale !== undefined && rasterScale > 0 ? rasterScale : this.rasterScale;
105
107
  // The key carries the size the glyph was *rasterised* at rather than the layout font size: two
106
108
  // font sizes that land on the same rung share one rasterisation, which is what keeps a block
107
109
  // whose size changes every frame from rasterising the same character over and over.
108
110
  const rasterPx = Math.max(1, Math.round(fontSize * scale));
109
- const key = `${fontFamily}|${rasterPx}|${character}`;
111
+ const faceKey = this.#rasterizer.faceKey?.(face) ?? fontFaceKey(face);
112
+ const key = `${faceKey}|${rasterPx}|${character}`;
110
113
  const cached = this.#glyphs.get(key);
111
114
  if (cached !== undefined) {
112
115
  cached.lastFrame = this.#frame;
@@ -114,7 +117,7 @@ export class GlyphAtlas {
114
117
  }
115
118
  if (this.#missing.has(key))
116
119
  return undefined;
117
- const rasterized = this.#rasterizer.rasterize(character, fontFamily, rasterPx);
120
+ const rasterized = this.#rasterizer.rasterize(character, face, rasterPx);
118
121
  if (rasterized === undefined) {
119
122
  this.#missing.add(key);
120
123
  return undefined;
@@ -13,7 +13,7 @@
13
13
  * Everything here is browser-only. Layout depends on the `TextMeasurer` interface alone, so
14
14
  * unit tests keep running in plain Node, and the GPU tests inject their own rasteriser.
15
15
  */
16
- import type { GlyphMetrics, TextMeasurer, VerticalMetrics } from "./text-layout.js";
16
+ import { type FontFace, type GlyphMetrics, type TextMeasurer, type VerticalMetrics } from "./text-layout.js";
17
17
  /**
18
18
  * How a glyph is stored in the atlas, and therefore how the shader has to decode it.
19
19
  *
@@ -36,6 +36,23 @@ export type GlyphEncoding = "bitmap" | "sdf" | "msdf";
36
36
  export declare const DEFAULT_FONT_FAMILY = "vgpu-render-fira-mono";
37
37
  /** Decodes a base64 payload into bytes. Shared by the inlined font and the prebaked atlas. */
38
38
  export declare function decodeBase64Bytes(base64: string): Uint8Array<ArrayBuffer>;
39
+ /** One inlined face of a multi-weight family: the weight it was built at, plus its WOFF2 bytes. */
40
+ export type EmbeddedFontFace = {
41
+ readonly weight: number;
42
+ /** Base64 WOFF2, so the payload stays plain text and every toolchain can read it. */
43
+ readonly woff2Base64: string;
44
+ };
45
+ /**
46
+ * Registers a family whose weights are separate faces, each carrying its own `weight` descriptor.
47
+ *
48
+ * This is the only way to get more than one drawn weight out of Canvas2D: the font shorthand carries a
49
+ * single synthesised bold step, and `font-variation-settings` does not exist there, but the browser
50
+ * does select a face by the weight it was registered with. Registering the same family once per
51
+ * weight therefore turns `weight: 700` into a real bold face instead of an emboldened regular one.
52
+ *
53
+ * Idempotent per family, like `loadDefaultFont`, and browser-only.
54
+ */
55
+ export declare function loadFontFaces(family: string, faces: readonly EmbeddedFontFace[]): Promise<void>;
39
56
  /** Decodes the inlined WOFF2 bytes of the bundled font, which is what `FontFace` takes. */
40
57
  export declare function decodeEmbeddedFont(): Uint8Array<ArrayBuffer>;
41
58
  /**
@@ -102,6 +119,15 @@ export interface GlyphRasterizer {
102
119
  * were rasterised once at a fixed size, so the renderer leaves it on `rasterScale` instead.
103
120
  */
104
121
  readonly dynamicRaster?: boolean;
122
+ /**
123
+ * Cache-key part for one face. The atlas keys glyphs by this plus the raster size, so a source that
124
+ * cannot change a face' axes reports just the parts it honours.
125
+ *
126
+ * The default keys on every axis (`fontFaceKey`). A source holding one fixed face — a prebaked page
127
+ * or a single parsed `opentype.js` buffer — overrides it so a weight the source cannot rasterise
128
+ * does not multiply identical atlas entries.
129
+ */
130
+ readonly faceKey?: (face: FontFace) => string;
105
131
  /**
106
132
  * Returns `undefined` for characters with no ink, such as whitespace.
107
133
  *
@@ -109,10 +135,25 @@ export interface GlyphRasterizer {
109
135
  * size: the atlas asks for the size the glyph will cover on screen, so the same character stays
110
136
  * sharp at any font size while still being rasterised once per size rung.
111
137
  */
112
- rasterize(character: string, fontFamily: string, rasterSize: number): RasterizedGlyph | undefined;
113
- /** Vertical metrics for a `fontSize`-sized face of `fontFamily`. */
114
- verticalMetrics(fontFamily: string, fontSize: number): VerticalMetrics;
138
+ rasterize(character: string, face: FontFace, rasterSize: number): RasterizedGlyph | undefined;
139
+ /** Vertical metrics for a `fontSize`-sized instance of `face`. */
140
+ verticalMetrics(face: FontFace, fontSize: number): VerticalMetrics;
115
141
  }
142
+ /**
143
+ * The CSS font shorthand that selects `face` at `fontSize`.
144
+ *
145
+ * Order follows the shorthand's grammar — style, weight, stretch, size, family — because Canvas2D
146
+ * parses the value as one. Two of the axes are conditional:
147
+ *
148
+ * - `font-stretch` keywords are only kept by the parser when the face has a `wdth` axis. A static
149
+ * face drops the keyword and draws every stop identically, which is why the bundled subset cannot
150
+ * show a `FontWidth` difference at all.
151
+ * - `italic` is the only slant Canvas2D keeps; `oblique <angle>` reads back as plain `italic`.
152
+ *
153
+ * A weight the loaded face does not carry is synthesised by the browser (one emboldened step, which
154
+ * keeps the advance), so `weight` at least changes what is drawn for the bundled static face.
155
+ */
156
+ export declare function canvasFontString(face: FontFace, fontSize: number): string;
116
157
  /** Encodings Canvas2D coverage can produce directly. `msdf` needs outlines, not a mask. */
117
158
  export type CoverageEncoding = Exclude<GlyphEncoding, "msdf">;
118
159
  /**
@@ -141,9 +182,9 @@ export declare class CanvasGlyphRasterizer implements GlyphRasterizer, TextMeasu
141
182
  rasterScale?: number;
142
183
  dynamicRaster?: boolean;
143
184
  });
144
- verticalMetrics(fontFamily: string, fontSize: number): VerticalMetrics;
145
- glyphMetrics(character: string, fontFamily: string, fontSize: number): GlyphMetrics;
146
- rasterize(character: string, fontFamily: string, rasterSize: number): RasterizedGlyph | undefined;
185
+ verticalMetrics(face: FontFace, fontSize: number): VerticalMetrics;
186
+ glyphMetrics(character: string, face: FontFace, fontSize: number): GlyphMetrics;
187
+ rasterize(character: string, face: FontFace, rasterSize: number): RasterizedGlyph | undefined;
147
188
  }
148
189
  /** Packs Canvas2D coverage into the RGBA layout the chosen encoding expects. */
149
190
  export declare function encodeCoverage(coverage: Uint8Array, width: number, height: number, encoding: CoverageEncoding, range: number): Uint8Array;