vgpu-render 0.1.0 → 0.3.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 (46) hide show
  1. package/README.md +101 -34
  2. package/dist/curve.d.ts +26 -1
  3. package/dist/curve.js +53 -0
  4. package/dist/debug-draw-3d.d.ts +3 -3
  5. package/dist/debug-draw-3d.js +10 -10
  6. package/dist/frame-draw-list-2d.d.ts +286 -0
  7. package/dist/frame-draw-list-2d.js +668 -0
  8. package/dist/{frame-recorder.d.ts → frame-draw-list.d.ts} +60 -8
  9. package/dist/{frame-recorder.js → frame-draw-list.js} +51 -10
  10. package/dist/geometry-generators.d.ts +11 -2
  11. package/dist/geometry-generators.js +37 -1
  12. package/dist/gpu-debug-draw-2d.d.ts +1 -1
  13. package/dist/gpu-debug-draw-2d.js +10 -2
  14. package/dist/gpu-debug-draw-3d.d.ts +1 -1
  15. package/dist/gpu-debug-draw-3d.js +4 -2
  16. package/dist/gpu-text-3d.d.ts +1 -1
  17. package/dist/gpu-text-3d.js +49 -2
  18. package/dist/index.d.ts +3 -3
  19. package/dist/index.js +2 -3
  20. package/dist/mesh-batch-3d.d.ts +1 -1
  21. package/dist/shape2d-shader.js +295 -7
  22. package/dist/text-atlas.d.ts +6 -4
  23. package/dist/text-atlas.js +9 -6
  24. package/dist/text-font.d.ts +48 -7
  25. package/dist/text-font.js +63 -11
  26. package/dist/text-layout.d.ts +63 -4
  27. package/dist/text-layout.js +58 -8
  28. package/dist/text-msdf-baked.d.ts +9 -4
  29. package/dist/text-msdf-baked.js +9 -4
  30. package/dist/text-msdf-font.d.ts +10 -4
  31. package/dist/text-msdf-font.js +9 -3
  32. package/dist/vector-math.d.ts +19 -0
  33. package/dist/vector-math.js +37 -0
  34. package/package.json +1 -1
  35. package/dist/frame-recorder-2d.d.ts +0 -177
  36. package/dist/frame-recorder-2d.js +0 -378
  37. package/dist/layout-3d.d.ts +0 -227
  38. package/dist/layout-3d.js +0 -226
  39. package/dist/scene-examples.d.ts +0 -39
  40. package/dist/scene-examples.js +0 -200
  41. package/dist/scenes-2d.d.ts +0 -18
  42. package/dist/scenes-2d.js +0 -160
  43. package/dist/scenes-3d.d.ts +0 -46
  44. package/dist/scenes-3d.js +0 -1994
  45. package/dist/text-scenes.d.ts +0 -85
  46. package/dist/text-scenes.js +0 -338
@@ -59,6 +59,8 @@ struct In {
59
59
  @location(12) color: vec4f,
60
60
  /** bit0: filled; bits 8..15: polygon vertex count. */
61
61
  @location(13) flags: u32,
62
+ /** (visible, gap, phase) of a dashed outline in shape-space units; gap 0 disables it. */
63
+ @location(14) dash: vec3f,
62
64
  }
63
65
 
64
66
  struct Out {
@@ -75,6 +77,7 @@ struct Out {
75
77
  @location(7) rotation: f32,
76
78
  @location(8) @interpolate(flat, either) sides: u32,
77
79
  @location(9) @interpolate(flat, either) flags: u32,
80
+ @location(10) dash: vec3f,
78
81
  }
79
82
 
80
83
  fn rotate2(p: vec2f, angle: f32) -> vec2f {
@@ -171,12 +174,17 @@ fn sd_rhombus(p: vec2f, a: f32, b: f32) -> f32 {
171
174
  }
172
175
 
173
176
  /** Sector of at most half a turn: the intersection of the disc and the two edge half-planes. */
174
- fn sd_convex_sector(p: vec2f, radius: f32, start: f32, sweep: f32) -> f32 {
177
+ /**
178
+ * Angular wedge of sweep radians from start: only the two bounding half-planes, no radius.
179
+ * Keeping the disc out of it matters for the complementary wedge below, where a radius term would
180
+ * clamp the opposite side to zero and leave the uncovered wedge of an arc or sector painted.
181
+ */
182
+ fn sd_wedge(p: vec2f, start: f32, sweep: f32) -> f32 {
175
183
  let end = start + sweep;
176
184
  // Inward normals of the two straight edges, in the order the sweep turns.
177
185
  let startEdge = vec2f(sin(start), -cos(start));
178
186
  let endEdge = vec2f(-sin(end), cos(end));
179
- return max(length(p) - radius, max(dot(p, startEdge), dot(p, endEdge)));
187
+ return max(dot(p, startEdge), dot(p, endEdge));
180
188
  }
181
189
 
182
190
  /**
@@ -186,9 +194,9 @@ fn sd_convex_sector(p: vec2f, radius: f32, start: f32, sweep: f32) -> f32 {
186
194
  fn sd_sector(p: vec2f, radius: f32, start: f32, sweep: f32) -> f32 {
187
195
  let disc = length(p) - radius;
188
196
  if (abs(sweep) >= TAU - EPSILON) { return disc; }
189
- if (abs(sweep) <= PI) { return max(disc, sd_convex_sector(p, radius, start, sweep)); }
197
+ if (abs(sweep) <= PI) { return max(disc, sd_wedge(p, start, sweep)); }
190
198
  let complementSweep = select(TAU + sweep, TAU - sweep, sweep > 0.0);
191
- let complement = sd_convex_sector(p, radius, start + sweep, complementSweep);
199
+ let complement = sd_wedge(p, start + sweep, complementSweep);
192
200
  return max(disc, -complement);
193
201
  }
194
202
 
@@ -213,8 +221,9 @@ fn shape_distance(shape: u32, p: vec2f, size: vec2f, vertexB: vec2f, vertexC: ve
213
221
  case SHAPE_ROUNDED_RECT: { return sd_rounded_box(p, size, cornerRadius); }
214
222
  case SHAPE_CIRCLE: { return sd_ellipse(p, size); }
215
223
  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); }
224
+ // An arc has no interior: the ring alone ignores the angles, so it is clipped to the swept
225
+ // wedge. Filling one still yields a hairline, because the distance stays unsigned.
226
+ case SHAPE_ARC: { return max(abs(length(p) - size.x), sd_sector(p, size.x, startAngle, sweep)); }
218
227
  case SHAPE_ANNULUS: { return sd_annulus(p, size.y, size.x); }
219
228
  case SHAPE_CAPSULE: { return sd_capsule(p, size.y, size.x); }
220
229
  case SHAPE_POLYGON: { return sd_regular_polygon(p, size.x, sides); }
@@ -226,6 +235,256 @@ fn shape_distance(shape: u32, p: vec2f, size: vec2f, vertexB: vec2f, vertexC: ve
226
235
  }
227
236
  }
228
237
 
238
+ /**
239
+ * Perimeter parameter of the point on a rectangle's outline nearest to the sample, measured
240
+ * anticlockwise from the (sx, sy) corner. The stroke only touches pixels within a pixel or two
241
+ * of the outline, so picking the closer of the two candidate edges there is exact.
242
+ */
243
+ fn rect_perimeter(p: vec2f, halfSize: vec2f) -> f32 {
244
+ let sx = halfSize.x;
245
+ let sy = halfSize.y;
246
+ let q = clamp(p, -halfSize, halfSize);
247
+ let overX = abs(p.x) - sx;
248
+ let overY = abs(p.y) - sy;
249
+ if (overY >= overX) {
250
+ if (p.y >= 0.0) { return sx - q.x; }
251
+ return 2.0 * sx + 2.0 * sy + (sx + q.x);
252
+ }
253
+ if (p.x < 0.0) { return 2.0 * sx + (sy - q.y); }
254
+ return 4.0 * sx + 2.0 * sy + (q.y + sy);
255
+ }
256
+
257
+ /**
258
+ * Closed-outline length in shape-space units, or -1 for an open outline (an arc). A closed
259
+ * outline snaps its dash period to a whole number of periods, so the pattern meets itself at the
260
+ * perimeter start instead of jumping by P mod period.
261
+ */
262
+ fn perimeter_total(shape: u32, size: vec2f, vertexB: vec2f, vertexC: vec2f, params: vec4f, sides: u32) -> f32 {
263
+ switch shape {
264
+ case SHAPE_RECT: { return 4.0 * (size.x + size.y); }
265
+ case SHAPE_ROUNDED_RECT: {
266
+ let corner = clamp(params.x, 0.0, min(size.x, size.y));
267
+ let inner = max(size - vec2f(corner), vec2f(0.0));
268
+ return 4.0 * (inner.x + inner.y) + 2.0 * PI * corner;
269
+ }
270
+ case SHAPE_CIRCLE: { return TAU * size.x; }
271
+ case SHAPE_ELLIPSE: { return ellipse_arc(TAU, size); }
272
+ case SHAPE_ARC: { return -1.0; }
273
+ case SHAPE_ANNULUS: { return TAU * (size.x + size.y); }
274
+ case SHAPE_CAPSULE: { return 2.0 * PI * size.x + 4.0 * size.y; }
275
+ case SHAPE_POLYGON: {
276
+ let count = max(sides, 3u);
277
+ return 2.0 * f32(count) * size.x * sin(PI / f32(count));
278
+ }
279
+ case SHAPE_TRIANGLE: {
280
+ return length(vertexB - size) + length(vertexC - vertexB) + length(size - vertexC);
281
+ }
282
+ case SHAPE_RHOMBUS: { return 4.0 * length(vec2f(0.0, size.y) - vec2f(size.x, 0.0)); }
283
+ case SHAPE_SECTOR: { return abs(params.w) * size.x + 2.0 * size.x; }
284
+ case SHAPE_SEGMENT: {
285
+ let sweepAbs = abs(params.w);
286
+ return sweepAbs * size.x + 2.0 * size.x * sin(sweepAbs * 0.5);
287
+ }
288
+ default: { return -1.0; }
289
+ }
290
+ }
291
+
292
+ /** Wraps an angle into [0, TAU). */
293
+ fn wrap_tau(angle: f32) -> f32 {
294
+ let wrapped = angle % TAU;
295
+ return select(wrapped + TAU, wrapped, wrapped >= 0.0);
296
+ }
297
+
298
+ /** Distance from the sample to the segment a-b, and how far along that segment it sits. */
299
+ fn nearest_edge(p: vec2f, a: vec2f, b: vec2f) -> vec2f {
300
+ let edge = b - a;
301
+ let along = clamp(dot(p - a, edge) / max(dot(edge, edge), EPSILON), 0.0, 1.0);
302
+ return vec2f(length(p - (a + edge * along)), along * length(edge));
303
+ }
304
+
305
+ /**
306
+ * Arc length of an ellipse from angle 0 to theta, Simpson-integrated in 12 steps. The ellipse
307
+ * has no elementary arc-length formula, so this is the one shape whose dash spacing is an
308
+ * approximation rather than a closed form.
309
+ */
310
+ fn ellipse_arc(theta: f32, radii: vec2f) -> f32 {
311
+ let steps = 12u;
312
+ let h = theta / f32(steps);
313
+ var sum = 0.0;
314
+ for (var i = 0u; i <= steps; i += 1u) {
315
+ let a = h * f32(i);
316
+ let speed = sqrt(radii.x * radii.x * sin(a) * sin(a) + radii.y * radii.y * cos(a) * cos(a));
317
+ var weight = 2.0;
318
+ if (i == 0u || i == steps) { weight = 1.0; }
319
+ else if (i % 2u == 1u) { weight = 4.0; }
320
+ sum = sum + weight * speed;
321
+ }
322
+ return sum * h / 3.0;
323
+ }
324
+
325
+ /** Rounded rectangle: four straight edges and four quarter arcs, from the top-right corner. */
326
+ fn rounded_rect_perimeter(p: vec2f, halfSize: vec2f, cornerRadius: f32) -> f32 {
327
+ let corner = clamp(cornerRadius, 0.0, min(halfSize.x, halfSize.y));
328
+ let inner = max(halfSize - vec2f(corner), vec2f(0.0));
329
+ let center = clamp(p, -inner, inner);
330
+ let delta = p - center;
331
+ let straightX = 2.0 * inner.x;
332
+ let straightY = 2.0 * inner.y;
333
+ let quarter = corner * PI * 0.5;
334
+ let leftBase = straightX + quarter;
335
+ let bottomBase = straightX + 2.0 * quarter + straightY;
336
+ // Right edge starts after the bottom-right quarter arc: two straight runs, three quarter
337
+ // arcs and one straight height have already been walked.
338
+ let rightBase = 2.0 * straightX + 3.0 * quarter + straightY;
339
+ let topRightBase = 2.0 * straightX + 2.0 * straightY + 3.0 * quarter;
340
+ if (abs(delta.x) > EPSILON && abs(delta.y) > EPSILON) {
341
+ let phi = wrap_tau(atan2(delta.y, delta.x));
342
+ if (phi < PI * 0.5) { return topRightBase + phi * corner; }
343
+ if (phi < PI) { return straightX + (phi - PI * 0.5) * corner; }
344
+ if (phi < PI * 1.5) { return leftBase + straightY + (phi - PI) * corner; }
345
+ return bottomBase + straightX + (phi - PI * 1.5) * corner;
346
+ }
347
+ if (abs(delta.y) > EPSILON) {
348
+ if (delta.y > 0.0) { return inner.x - p.x; }
349
+ return bottomBase + (p.x + inner.x);
350
+ }
351
+ if (delta.x > 0.0) { return rightBase + (p.y + inner.y); }
352
+ return leftBase + (inner.y - p.y);
353
+ }
354
+
355
+ /** Capsule: upper cap, left edge, lower cap, right edge, from the upper-right cap start. */
356
+ fn capsule_perimeter(p: vec2f, radius: f32, halfLength: f32) -> f32 {
357
+ let arc = PI * radius;
358
+ if (p.y >= halfLength) {
359
+ return clamp(atan2(p.y - halfLength, p.x), 0.0, PI) * radius;
360
+ }
361
+ if (p.y <= -halfLength) {
362
+ let angle = clamp(wrap_tau(atan2(p.y + halfLength, p.x)), PI, TAU);
363
+ return arc + 2.0 * halfLength + (angle - PI) * radius;
364
+ }
365
+ if (p.x >= 0.0) { return 2.0 * arc + 2.0 * halfLength + (p.y + halfLength); }
366
+ return arc + (halfLength - p.y);
367
+ }
368
+
369
+ /** Annulus: outer ring forwards, then inner ring backwards, so the band stays continuous. */
370
+ fn annulus_perimeter(p: vec2f, outer: f32, inner: f32) -> f32 {
371
+ let radius = length(p);
372
+ let angle = wrap_tau(atan2(p.y, p.x));
373
+ if (abs(radius - outer) <= abs(radius - inner)) { return angle * outer; }
374
+ return TAU * outer + (TAU - angle) * inner;
375
+ }
376
+
377
+ /** Arc: only the swept wedge has a perimeter, so samples past an end clamp to that end. */
378
+ fn arc_perimeter(p: vec2f, radius: f32, start: f32, sweep: f32) -> f32 {
379
+ let sweepAbs = abs(sweep);
380
+ let angle = wrap_tau(atan2(p.y, p.x));
381
+ var offset = wrap_tau(angle - start);
382
+ if (sweep < 0.0) { offset = wrap_tau(start - angle); }
383
+ return min(offset, sweepAbs) * radius;
384
+ }
385
+
386
+ /** Sector: arc, then the radius at the arc end, then the radius back to the arc start. */
387
+ fn sector_perimeter(p: vec2f, radius: f32, start: f32, sweep: f32) -> f32 {
388
+ let sweepAbs = abs(sweep);
389
+ let angle = wrap_tau(atan2(p.y, p.x));
390
+ var offset = wrap_tau(angle - start);
391
+ if (sweep < 0.0) { offset = wrap_tau(start - angle); }
392
+ let startDir = vec2f(cos(start), sin(start)) * radius;
393
+ let endDir = vec2f(cos(start + sweep), sin(start + sweep)) * radius;
394
+ let startEdge = nearest_edge(p, vec2f(0.0), startDir);
395
+ let endEdge = nearest_edge(p, vec2f(0.0), endDir);
396
+ let arcLength = sweepAbs * radius;
397
+ let arcDistance = select(abs(length(p) - radius), min(length(p - startDir), length(p - endDir)), offset > sweepAbs);
398
+ if (arcDistance <= startEdge.x && arcDistance <= endEdge.x) {
399
+ return min(offset, sweepAbs) * radius;
400
+ }
401
+ if (endEdge.x <= startEdge.x) { return arcLength + (radius - endEdge.y); }
402
+ return arcLength + radius + startEdge.y;
403
+ }
404
+
405
+ /** Segment: the arc, then the chord from the arc end back to its start. */
406
+ fn segment_perimeter(p: vec2f, radius: f32, start: f32, sweep: f32) -> f32 {
407
+ let sweepAbs = abs(sweep);
408
+ let angle = wrap_tau(atan2(p.y, p.x));
409
+ var offset = wrap_tau(angle - start);
410
+ if (sweep < 0.0) { offset = wrap_tau(start - angle); }
411
+ let arcStart = vec2f(cos(start), sin(start)) * radius;
412
+ let arcEnd = vec2f(cos(start + sweep), sin(start + sweep)) * radius;
413
+ let chord = nearest_edge(p, arcEnd, arcStart);
414
+ let arcLength = sweepAbs * radius;
415
+ let arcDistance = select(abs(length(p) - radius), min(length(p - arcStart), length(p - arcEnd)), offset > sweepAbs);
416
+ if (arcDistance <= chord.x) { return min(offset, sweepAbs) * radius; }
417
+ return arcLength + chord.y;
418
+ }
419
+
420
+ /** Regular polygon: pick the angular sector, then the projection onto that edge. */
421
+ fn polygon_perimeter(p: vec2f, radius: f32, sides: u32) -> f32 {
422
+ let count = max(sides, 3u);
423
+ let step = TAU / f32(count);
424
+ let edgeLength = 2.0 * radius * sin(PI / f32(count));
425
+ let angle = wrap_tau(atan2(p.y, p.x));
426
+ let index = min(u32(floor(angle / step)), count - 1u);
427
+ let start = vec2f(cos(step * f32(index)), sin(step * f32(index))) * radius;
428
+ let end = vec2f(cos(step * f32(index + 1u)), sin(step * f32(index + 1u))) * radius;
429
+ return f32(index) * edgeLength + nearest_edge(p, start, end).y;
430
+ }
431
+
432
+ /** Triangle: three edges, from the first vertex. */
433
+ fn triangle_perimeter(p: vec2f, a: vec2f, b: vec2f, c: vec2f) -> f32 {
434
+ let ab = nearest_edge(p, a, b);
435
+ let bc = nearest_edge(p, b, c);
436
+ let ca = nearest_edge(p, c, a);
437
+ let lab = length(b - a);
438
+ let lbc = length(c - b);
439
+ if (ab.x <= bc.x && ab.x <= ca.x) { return ab.y; }
440
+ if (bc.x <= ca.x) { return lab + bc.y; }
441
+ return lab + lbc + ca.y;
442
+ }
443
+
444
+ /** Rhombus: four edges through the four diagonal tips, from the +x tip. */
445
+ fn rhombus_perimeter(p: vec2f, halfWidth: f32, halfHeight: f32) -> f32 {
446
+ let v0 = vec2f(halfWidth, 0.0);
447
+ let v1 = vec2f(0.0, halfHeight);
448
+ let v2 = vec2f(-halfWidth, 0.0);
449
+ let v3 = vec2f(0.0, -halfHeight);
450
+ let e0 = nearest_edge(p, v0, v1);
451
+ let e1 = nearest_edge(p, v1, v2);
452
+ let e2 = nearest_edge(p, v2, v3);
453
+ let e3 = nearest_edge(p, v3, v0);
454
+ let l0 = length(v1 - v0);
455
+ let l1 = length(v2 - v1);
456
+ let l2 = length(v3 - v2);
457
+ if (e0.x <= e1.x && e0.x <= e2.x && e0.x <= e3.x) { return e0.y; }
458
+ if (e1.x <= e2.x && e1.x <= e3.x) { return l0 + e1.y; }
459
+ if (e2.x <= e3.x) { return l0 + l1 + e2.y; }
460
+ return l0 + l1 + l2 + e3.y;
461
+ }
462
+
463
+ /**
464
+ * Arc length along the outline at the nearest outline point, in shape-space units. Every
465
+ * primitive now has a closed form except the ellipse, which is integrated numerically.
466
+ */
467
+ fn perimeter_length(shape: u32, p: vec2f, size: vec2f, vertexB: vec2f, vertexC: vec2f, params: vec4f, sides: u32) -> f32 {
468
+ switch shape {
469
+ case SHAPE_RECT: { return rect_perimeter(p, size); }
470
+ case SHAPE_ROUNDED_RECT: { return rounded_rect_perimeter(p, size, params.x); }
471
+ case SHAPE_CIRCLE: { return wrap_tau(atan2(p.y, p.x)) * size.x; }
472
+ case SHAPE_ELLIPSE: {
473
+ let theta = wrap_tau(atan2(p.y / max(size.y, EPSILON), p.x / max(size.x, EPSILON)));
474
+ return ellipse_arc(theta, size);
475
+ }
476
+ case SHAPE_ARC: { return arc_perimeter(p, size.x, params.z, params.w); }
477
+ case SHAPE_ANNULUS: { return annulus_perimeter(p, size.x, size.y); }
478
+ case SHAPE_CAPSULE: { return capsule_perimeter(p, size.x, size.y); }
479
+ case SHAPE_POLYGON: { return polygon_perimeter(p, size.x, sides); }
480
+ case SHAPE_TRIANGLE: { return triangle_perimeter(p, size, vertexB, vertexC); }
481
+ case SHAPE_RHOMBUS: { return rhombus_perimeter(p, size.x, size.y); }
482
+ case SHAPE_SECTOR: { return sector_perimeter(p, size.x, params.z, params.w); }
483
+ case SHAPE_SEGMENT: { return segment_perimeter(p, size.x, params.z, params.w); }
484
+ default: { return -1.0; }
485
+ }
486
+ }
487
+
229
488
  fn clip_to_framebuffer(clip: vec4f) -> vec2f {
230
489
  let ndc = clip.xy / max(clip.w, EPSILON);
231
490
  return vec2f((ndc.x * 0.5 + 0.5) * camera.viewportSize.x, (0.5 - ndc.y * 0.5) * camera.viewportSize.y);
@@ -263,6 +522,7 @@ fn clip_to_framebuffer(clip: vec4f) -> vec2f {
263
522
  out.rotation = rotation;
264
523
  out.sides = (input.flags >> 8u) & 0xffu;
265
524
  out.flags = input.flags;
525
+ out.dash = input.dash;
266
526
  return out;
267
527
  }
268
528
 
@@ -279,7 +539,35 @@ fn clip_to_framebuffer(clip: vec4f) -> vec2f {
279
539
  // Relative to the outer edge of the stroke, so one coverage ramp serves fill and stroke alike.
280
540
  let signedPixel = select(abs(distancePixel) - max(input.params.y, 0.0) * 0.5, distancePixel, filled);
281
541
  let width = max(fwidth(signedPixel), EPSILON);
282
- let coverage = clamp(0.5 - signedPixel / width, 0.0, 1.0);
542
+ var coverage = clamp(0.5 - signedPixel / width, 0.0, 1.0);
543
+ if (coverage <= 0.0) { discard; }
544
+
545
+ // Dashed outlines: walk the perimeter parameter and cut the stroke in the gaps. The transition
546
+ // is spread over one canvas pixel so a dash edge is as soft as the shape's own edge.
547
+ if ((input.flags & 1u) == 0u && input.dash.y > 0.0) {
548
+ let path = perimeter_length(input.shape, local, input.size, input.vertexB, input.vertexC, input.params, input.sides);
549
+ if (path >= 0.0) {
550
+ let requested = input.dash.x + input.dash.y;
551
+ let total = perimeter_total(input.shape, input.size, input.vertexB, input.vertexC, input.params, input.sides);
552
+ var period = requested;
553
+ var visible = input.dash.x;
554
+ if (total > 0.0) {
555
+ // Snap the period to a whole number of turns, keeping the requested duty cycle: the
556
+ // closed outline then ends exactly where its dash pattern started.
557
+ let count = max(round(total / requested), 1.0);
558
+ period = total / count;
559
+ visible = period * (input.dash.x / requested);
560
+ }
561
+ let along = fract((path + input.dash.z) / period) * period;
562
+ let pixel = 1.0 / scale;
563
+ // Distance to the nearest pattern boundary: the dash start, the dash end, and the next
564
+ // dash start that fract() wraps around to.
565
+ let edge = min(min(along, abs(along - visible)), period - along);
566
+ var dashCoverage = smoothstep(0.0, pixel, edge);
567
+ if (along > visible) { dashCoverage = 1.0 - dashCoverage; }
568
+ coverage = coverage * dashCoverage;
569
+ }
570
+ }
283
571
  if (coverage <= 0.0) { discard; }
284
572
  return vec4f(input.color.rgb, input.color.a * coverage);
285
573
  }
@@ -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;
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";