vgpu-render 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +605 -2
  3. package/dist/assets/fira-mono-medium-subset.d.ts +4 -0
  4. package/dist/assets/fira-mono-medium-subset.js +889 -0
  5. package/dist/assets/fira-mono-msdf-atlas.d.ts +13 -0
  6. package/dist/assets/fira-mono-msdf-atlas.js +6222 -0
  7. package/dist/camera-2d.d.ts +43 -0
  8. package/dist/camera-2d.js +75 -0
  9. package/dist/color-space.d.ts +32 -0
  10. package/dist/color-space.js +45 -0
  11. package/dist/curve.d.ts +16 -0
  12. package/dist/curve.js +46 -0
  13. package/dist/debug-draw-3d.d.ts +14 -0
  14. package/dist/debug-draw-3d.js +36 -0
  15. package/dist/frame-draw-list-2d.d.ts +177 -0
  16. package/dist/frame-draw-list-2d.js +378 -0
  17. package/dist/frame-draw-list.d.ts +413 -0
  18. package/dist/frame-draw-list.js +556 -0
  19. package/dist/geometry-generators.d.ts +101 -0
  20. package/dist/geometry-generators.js +481 -0
  21. package/dist/gpu-debug-draw-2d.d.ts +48 -0
  22. package/dist/gpu-debug-draw-2d.js +450 -0
  23. package/dist/gpu-debug-draw-3d.d.ts +52 -0
  24. package/dist/gpu-debug-draw-3d.js +892 -0
  25. package/dist/gpu-instancing.d.ts +34 -0
  26. package/dist/gpu-instancing.js +93 -0
  27. package/dist/gpu-text-3d.d.ts +98 -0
  28. package/dist/gpu-text-3d.js +514 -0
  29. package/dist/index.d.ts +21 -0
  30. package/dist/index.js +20 -0
  31. package/dist/infinite-grid-2d.d.ts +73 -0
  32. package/dist/infinite-grid-2d.js +253 -0
  33. package/dist/infinite-grid-3d.d.ts +134 -0
  34. package/dist/infinite-grid-3d.js +407 -0
  35. package/dist/mesh-batch-3d.d.ts +10 -0
  36. package/dist/mesh-batch-3d.js +67 -0
  37. package/dist/shape2d-shader.d.ts +14 -0
  38. package/dist/shape2d-shader.js +288 -0
  39. package/dist/text-atlas.d.ts +176 -0
  40. package/dist/text-atlas.js +498 -0
  41. package/dist/text-font.d.ts +190 -0
  42. package/dist/text-font.js +261 -0
  43. package/dist/text-layout.d.ts +175 -0
  44. package/dist/text-layout.js +390 -0
  45. package/dist/text-msdf-baked.d.ts +41 -0
  46. package/dist/text-msdf-baked.js +117 -0
  47. package/dist/text-msdf-font.d.ts +85 -0
  48. package/dist/text-msdf-font.js +159 -0
  49. package/dist/text-msdf.d.ts +70 -0
  50. package/dist/text-msdf.js +242 -0
  51. package/dist/text-raster.d.ts +40 -0
  52. package/dist/text-raster.js +60 -0
  53. package/dist/text-sdf.d.ts +28 -0
  54. package/dist/text-sdf.js +119 -0
  55. package/dist/vector-math.d.ts +19 -0
  56. package/dist/vector-math.js +37 -0
  57. package/package.json +80 -4
@@ -0,0 +1,498 @@
1
+ /**
2
+ * Glyph atlas for the text renderer.
3
+ *
4
+ * The equivalent of reference `FontAtlas` / `FontAtlasSet`
5
+ * (`the reference implementation`, `font_atlas_set.rs`): rasterised glyphs are packed
6
+ * into one or more pages and remembered by their atlas rectangle plus the ink offset that
7
+ * places them relative to the pen. reference stores every glyph as a tintable alpha mask
8
+ * (RGB = 255, A = coverage) so the shader can colour it, and uses a minimum 512 px page with
9
+ * 2 px of padding; both choices are kept here, since the padding is what keeps linear
10
+ * sampling from bleeding between neighbours.
11
+ *
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
16
+ * size — the same separation as resampling a font at a higher point size before scaling it down.
17
+ * Pages grow on demand until `maxPages` is reached — the GPU renderer pins that at one page so a
18
+ * single draw call covers the whole atlas — and a `revision` counter tells the renderer which of
19
+ * them changed since the last upload.
20
+ */
21
+ import { fontFaceKey } from "./text-layout.js";
22
+ /** Page size and inter-glyph padding, matching reference minimum atlas and `GLYPH_ATLAS_PADDING`. */
23
+ export const DEFAULT_ATLAS_SIZE = 512;
24
+ export const DEFAULT_ATLAS_PADDING = 2;
25
+ /**
26
+ * Which channels hold a distance, per encoding. A mip chain has to resample those through coverage
27
+ * rather than averaging the values; everything else in a texel is signal that averages.
28
+ */
29
+ export const DISTANCE_CHANNELS = {
30
+ bitmap: [],
31
+ sdf: [0],
32
+ msdf: [0, 1, 2],
33
+ };
34
+ export class GlyphAtlas {
35
+ #rasterizer;
36
+ #size;
37
+ #padding;
38
+ #maxPages;
39
+ #onFull;
40
+ #skipped = 0;
41
+ #pages = [];
42
+ #packing = [];
43
+ #glyphs = new Map();
44
+ #missing = new Set();
45
+ /** Cache keys per page, so a page can be recycled as a whole when every glyph on it is stale. */
46
+ #pageGlyphs = [];
47
+ /** Rectangles handed back by recycling a single stale glyph, reusable for later ones. */
48
+ #free = [];
49
+ #frame = 0;
50
+ constructor(rasterizer, options = {}) {
51
+ this.#rasterizer = rasterizer;
52
+ this.#size = options.size ?? DEFAULT_ATLAS_SIZE;
53
+ this.#padding = options.padding ?? DEFAULT_ATLAS_PADDING;
54
+ this.#maxPages = options.maxPages ?? Number.POSITIVE_INFINITY;
55
+ this.#onFull = options.onFull ?? "throw";
56
+ if (!Number.isInteger(this.#size) || this.#size <= 0)
57
+ throw new RangeError("atlas size must be a positive integer");
58
+ if (!Number.isInteger(this.#padding) || this.#padding < 0)
59
+ throw new RangeError("atlas padding must be a non-negative integer");
60
+ if (this.#maxPages < 1)
61
+ throw new RangeError("atlas maxPages must be at least one");
62
+ if (!(this.rasterScale > 0))
63
+ throw new RangeError("the glyph source's rasterScale must be greater than zero");
64
+ }
65
+ /** The encoding the glyph source writes, which is also how the shader will decode the page. */
66
+ get encoding() {
67
+ return this.#rasterizer.encoding;
68
+ }
69
+ /** The distance range the source encodes with, if it uses a distance field at all. */
70
+ get distanceRange() {
71
+ return this.#rasterizer.distanceRange;
72
+ }
73
+ /** How much larger the source's texels are than layout units. See `GlyphRasterizer.rasterScale`. */
74
+ get rasterScale() {
75
+ return this.#rasterizer.rasterScale ?? 1;
76
+ }
77
+ get pages() {
78
+ return this.#pages;
79
+ }
80
+ /**
81
+ * Glyphs dropped because the atlas was full and no page could be recycled, which only happens when
82
+ * the caller chose `onFull: "skip"`. A renderer reports it so a frame that is quietly missing a
83
+ * character can be told apart from one the font never had.
84
+ */
85
+ get skipped() {
86
+ return this.#skipped;
87
+ }
88
+ /**
89
+ * Marks the start of a frame. Glyphs that were not requested in the current frame become
90
+ * recyclable, so a bounded atlas stops being a hard failure once on-screen text changes shape.
91
+ * Call it once per frame, before the frame's glyphs are requested.
92
+ */
93
+ beginFrame() {
94
+ this.#frame++;
95
+ }
96
+ /**
97
+ * Returns the atlas entry for a character, rasterising and packing it on first use.
98
+ * `undefined` means the character has no ink (whitespace) or the rasteriser has nothing for
99
+ * it; the layout layer has already dropped those glyphs, so callers treat it as a skip.
100
+ *
101
+ * `rasterScale` defaults to the source's own scale, which keeps a fixed-raster source drawing
102
+ * exactly as it did. The renderer passes the size the block covers on screen instead, so the entry
103
+ * that comes back is rasterised for the rung that face asked for.
104
+ */
105
+ glyph(character, face, fontSize, rasterScale) {
106
+ const scale = rasterScale !== undefined && rasterScale > 0 ? rasterScale : this.rasterScale;
107
+ // The key carries the size the glyph was *rasterised* at rather than the layout font size: two
108
+ // font sizes that land on the same rung share one rasterisation, which is what keeps a block
109
+ // whose size changes every frame from rasterising the same character over and over.
110
+ const rasterPx = Math.max(1, Math.round(fontSize * scale));
111
+ const faceKey = this.#rasterizer.faceKey?.(face) ?? fontFaceKey(face);
112
+ const key = `${faceKey}|${rasterPx}|${character}`;
113
+ const cached = this.#glyphs.get(key);
114
+ if (cached !== undefined) {
115
+ cached.lastFrame = this.#frame;
116
+ return cached;
117
+ }
118
+ if (this.#missing.has(key))
119
+ return undefined;
120
+ const rasterized = this.#rasterizer.rasterize(character, face, rasterPx);
121
+ if (rasterized === undefined) {
122
+ this.#missing.add(key);
123
+ return undefined;
124
+ }
125
+ const placement = this.#pack(rasterized.width, rasterized.height);
126
+ if (placement === undefined) {
127
+ if (this.#onFull === "throw") {
128
+ throw new RangeError(`the glyph atlas is full: ${this.#pages.length} page(s) of ${this.#size}px no longer fit a new glyph; raise the atlas size or the page count`);
129
+ }
130
+ // Capacity, not the font: the next frame may have room again, so this is not cached as missing.
131
+ this.#skipped++;
132
+ return undefined;
133
+ }
134
+ const page = this.#pages[placement.page];
135
+ this.#blit(page, placement.x, placement.y, rasterized.width, rasterized.height, rasterized.texels);
136
+ const glyph = {
137
+ page: placement.page,
138
+ // Only the ink box is mapped, not the rounded-up image, so the quad and the texels agree.
139
+ uv: [
140
+ placement.x / this.#size,
141
+ placement.y / this.#size,
142
+ (placement.x + rasterized.inkWidth) / this.#size,
143
+ (placement.y + rasterized.inkHeight) / this.#size,
144
+ ],
145
+ bearingLeft: rasterized.bearingLeft,
146
+ bearingTop: rasterized.bearingTop,
147
+ width: rasterized.inkWidth,
148
+ height: rasterized.inkHeight,
149
+ advance: rasterized.advance,
150
+ rasterScale: rasterPx / fontSize,
151
+ lastFrame: this.#frame,
152
+ atlasX: placement.x,
153
+ atlasY: placement.y,
154
+ atlasWidth: rasterized.width,
155
+ atlasHeight: rasterized.height,
156
+ };
157
+ this.#glyphs.set(key, glyph);
158
+ this.#pageGlyphs[placement.page].add(key);
159
+ return glyph;
160
+ }
161
+ /** Drops every cached glyph. The next frame re-rasterises whatever it still draws. */
162
+ clear() {
163
+ this.#glyphs.clear();
164
+ this.#missing.clear();
165
+ this.#free.length = 0;
166
+ for (const keys of this.#pageGlyphs)
167
+ keys.clear();
168
+ for (const page of this.#pages) {
169
+ page.data.fill(0);
170
+ page.revision++;
171
+ // Blanking the whole page is a full-page upload, not a few dirty rectangles.
172
+ page.dirty = { x: 0, y: 0, width: page.size, height: page.size };
173
+ }
174
+ for (const packing of this.#packing) {
175
+ packing.nextY = 0;
176
+ packing.shelves.length = 0;
177
+ }
178
+ }
179
+ #createPage() {
180
+ const index = this.#pages.length;
181
+ this.#pages.push({ index, size: this.#size, data: new Uint8Array(this.#size * this.#size * 4), revision: 0, dirty: undefined });
182
+ this.#packing.push({ nextY: 0, shelves: [] });
183
+ this.#pageGlyphs.push(new Set());
184
+ return index;
185
+ }
186
+ /**
187
+ * Blanks a page whose glyphs were all requested in an earlier frame and returns its index, or
188
+ * `undefined` when no page is entirely stale. Recycling a whole page resets its shelf packing,
189
+ * which is what makes it the preferred path over handing single rectangles back.
190
+ */
191
+ #recyclePage() {
192
+ let victim = -1;
193
+ let oldest = Number.POSITIVE_INFINITY;
194
+ for (let index = 0; index < this.#pages.length; index++) {
195
+ let newest = -1;
196
+ for (const key of this.#pageGlyphs[index]) {
197
+ const entry = this.#glyphs.get(key);
198
+ if (entry !== undefined && entry.lastFrame > newest)
199
+ newest = entry.lastFrame;
200
+ }
201
+ if (newest >= this.#frame)
202
+ continue;
203
+ if (newest < oldest) {
204
+ oldest = newest;
205
+ victim = index;
206
+ }
207
+ }
208
+ if (victim === -1)
209
+ return undefined;
210
+ const page = this.#pages[victim];
211
+ page.data.fill(0);
212
+ page.revision++;
213
+ page.dirty = { x: 0, y: 0, width: page.size, height: page.size };
214
+ const packing = this.#packing[victim];
215
+ packing.nextY = 0;
216
+ packing.shelves.length = 0;
217
+ for (const key of this.#pageGlyphs[victim])
218
+ this.#glyphs.delete(key);
219
+ this.#pageGlyphs[victim].clear();
220
+ return victim;
221
+ }
222
+ /**
223
+ * Falls back to recycling single stale glyphs, oldest first, until one of their rectangles can
224
+ * hold the new glyph. This is what covers a page that mixes glyphs from the current frame with
225
+ * ones from earlier frames: the whole page cannot be blanked, but the stale part can be reused.
226
+ */
227
+ #recycleGlyphs(w, h) {
228
+ const stale = [];
229
+ for (const [key, glyph] of this.#glyphs) {
230
+ if (glyph.lastFrame < this.#frame)
231
+ stale.push({ key, glyph });
232
+ }
233
+ if (stale.length === 0)
234
+ return undefined;
235
+ stale.sort((a, b) => a.glyph.lastFrame - b.glyph.lastFrame);
236
+ for (const { key, glyph } of stale) {
237
+ this.#release(key, glyph);
238
+ const placement = this.#takeFree(w, h);
239
+ if (placement !== undefined)
240
+ return placement;
241
+ }
242
+ return undefined;
243
+ }
244
+ /** Hands one glyph's packed rectangle back, sized to include the packing padding. */
245
+ #release(key, glyph) {
246
+ this.#glyphs.delete(key);
247
+ this.#pageGlyphs[glyph.page].delete(key);
248
+ this.#free.push({
249
+ page: glyph.page,
250
+ x: glyph.atlasX,
251
+ y: glyph.atlasY,
252
+ width: glyph.atlasWidth + this.#padding,
253
+ height: glyph.atlasHeight + this.#padding,
254
+ });
255
+ }
256
+ /** Takes the smallest recycled rectangle that fits, splitting off whatever it does not use. */
257
+ #takeFree(w, h) {
258
+ let best = -1;
259
+ let bestArea = Number.POSITIVE_INFINITY;
260
+ for (let index = 0; index < this.#free.length; index++) {
261
+ const rect = this.#free[index];
262
+ if (rect.width < w || rect.height < h)
263
+ continue;
264
+ const area = rect.width * rect.height;
265
+ if (area < bestArea) {
266
+ bestArea = area;
267
+ best = index;
268
+ }
269
+ }
270
+ if (best === -1)
271
+ return undefined;
272
+ const rect = this.#free[best];
273
+ this.#free.splice(best, 1);
274
+ if (rect.width > w)
275
+ this.#free.push({ page: rect.page, x: rect.x + w, y: rect.y, width: rect.width - w, height: rect.height });
276
+ if (rect.height > h)
277
+ this.#free.push({ page: rect.page, x: rect.x, y: rect.y + h, width: Math.min(rect.width, w), height: rect.height - h });
278
+ return { page: rect.page, x: rect.x, y: rect.y };
279
+ }
280
+ /**
281
+ * Shelf packing: glyphs fill a row until it runs out of width, then a new row starts below.
282
+ * reference uses a rectangle packer, but for the handful of sizes a debug frame draws, shelves
283
+ * waste little and keep placement deterministic.
284
+ *
285
+ * `undefined` means every page is full and nothing on them was recyclable. The caller decides what
286
+ * that costs; see `onFull`.
287
+ */
288
+ #pack(width, height) {
289
+ const w = width + this.#padding;
290
+ const h = height + this.#padding;
291
+ if (w > this.#size || h > this.#size) {
292
+ throw new RangeError(`a ${width}×${height} glyph does not fit a ${this.#size}px atlas page`);
293
+ }
294
+ const free = this.#takeFree(w, h);
295
+ if (free !== undefined)
296
+ return free;
297
+ for (let index = 0; index < this.#pages.length; index++) {
298
+ const placement = packInto(this.#packing[index], w, h, this.#size);
299
+ if (placement !== undefined)
300
+ return { page: index, x: placement.x, y: placement.y };
301
+ }
302
+ const recycledPage = this.#recyclePage();
303
+ if (recycledPage !== undefined) {
304
+ const placement = packInto(this.#packing[recycledPage], w, h, this.#size);
305
+ return { page: recycledPage, x: placement.x, y: placement.y };
306
+ }
307
+ const recycled = this.#recycleGlyphs(w, h);
308
+ if (recycled !== undefined)
309
+ return recycled;
310
+ if (this.#pages.length >= this.#maxPages) {
311
+ return undefined;
312
+ }
313
+ const index = this.#createPage();
314
+ const placement = packInto(this.#packing[index], w, h, this.#size);
315
+ // `#createPage` starts from an empty page, and the size check above proved the glyph fits.
316
+ return { page: index, x: placement.x, y: placement.y };
317
+ }
318
+ /** Copies one glyph's texels into the page. The encoding is already baked into them. */
319
+ #blit(page, x, y, width, height, texels) {
320
+ const rowBytes = page.size * 4;
321
+ const glyphRowBytes = width * 4;
322
+ for (let row = 0; row < height; row++) {
323
+ page.data.set(texels.subarray(row * glyphRowBytes, (row + 1) * glyphRowBytes), (y + row) * rowBytes + x * 4);
324
+ }
325
+ page.revision++;
326
+ page.dirty = extendDirty(page.dirty, { x, y, width, height });
327
+ }
328
+ }
329
+ /** Grows a dirty rectangle so it also covers `next`. */
330
+ function extendDirty(current, next) {
331
+ if (current === undefined)
332
+ return next;
333
+ const x = Math.min(current.x, next.x);
334
+ const y = Math.min(current.y, next.y);
335
+ const right = Math.max(current.x + current.width, next.x + next.width);
336
+ const bottom = Math.max(current.y + current.height, next.y + next.height);
337
+ return { x, y, width: right - x, height: bottom - y };
338
+ }
339
+ /**
340
+ * Maps a level-0 dirty rectangle onto one mip level. Every level halves the coordinates and grows
341
+ * the rectangle by one texel, because a box filter reads across the edge it averages.
342
+ */
343
+ export function dirtyAtLevel(dirty, level, pageSize) {
344
+ let size = pageSize;
345
+ let left = dirty.x;
346
+ let top = dirty.y;
347
+ let right = dirty.x + dirty.width;
348
+ let bottom = dirty.y + dirty.height;
349
+ for (let step = 0; step < level; step++) {
350
+ size = Math.max(1, size >> 1);
351
+ left = Math.max(0, (left >> 1) - 1);
352
+ top = Math.max(0, (top >> 1) - 1);
353
+ right = Math.min(size, Math.ceil(right / 2) + 1);
354
+ bottom = Math.min(size, Math.ceil(bottom / 2) + 1);
355
+ }
356
+ return { x: left, y: top, width: Math.max(1, right - left), height: Math.max(1, bottom - top) };
357
+ }
358
+ /**
359
+ * Box-downsamples one mip level: every output texel averages its 2×2 source block, clamped at the
360
+ * edge. The page is a tintable mask — RGB is a constant 255 and all the signal sits in the coverage
361
+ * channel — so averaging is exactly the right minification filter here. A distance field would need
362
+ * the usual distance-compression trick instead, which is why an encoder that stores one keeps its
363
+ * own resampling.
364
+ */
365
+ export function downsamplePage(data, size) {
366
+ const half = Math.max(1, size >> 1);
367
+ const result = new Uint8Array(half * half * 4);
368
+ for (let y = 0; y < half; y++) {
369
+ for (let x = 0; x < half; x++) {
370
+ const sums = [0, 0, 0, 0];
371
+ for (let dy = 0; dy < 2; dy++) {
372
+ const sourceY = Math.min(y * 2 + dy, size - 1);
373
+ for (let dx = 0; dx < 2; dx++) {
374
+ const sourceX = Math.min(x * 2 + dx, size - 1);
375
+ const index = (sourceY * size + sourceX) * 4;
376
+ for (let channel = 0; channel < 4; channel++)
377
+ sums[channel] += data[index + channel];
378
+ }
379
+ }
380
+ const target = (y * half + x) * 4;
381
+ for (let channel = 0; channel < 4; channel++)
382
+ result[target + channel] = Math.round(sums[channel] / 4);
383
+ }
384
+ }
385
+ return result;
386
+ }
387
+ /** The coverage an encoded distance reads back as, given the field's range in texels. */
388
+ function readCoverage(value, range) {
389
+ const coverage = (value / 255 - 0.5) * 2 * range + 0.5;
390
+ return coverage < 0 ? 0 : coverage > 1 ? 1 : coverage;
391
+ }
392
+ /** The encoded value that reads back as a given coverage. */
393
+ function encodeCoverage(coverage, range) {
394
+ const value = Math.round(((coverage - 0.5) / (2 * range) + 0.5) * 255);
395
+ return value < 0 ? 0 : value > 255 ? 255 : value;
396
+ }
397
+ /**
398
+ * Box-downsamples one mip level of a distance field.
399
+ *
400
+ * Averaging stored distances is not the same filter. A distance is the signed distance to the nearest
401
+ * edge, so where the field saturates its magnitude carries no information — a block that is half
402
+ * deeply-inside ink and half just-outside ink averages to a value that says "far inside" while its
403
+ * real coverage is a half — and a level that feeds that back through `clamp(distance + 0.5)` loses or
404
+ * invents ink. Measured against the geometric coverage of the same glyphs, a level built this way
405
+ * reads about 10% light. Decoding each value as the coverage it implies, averaging that, and
406
+ * encoding the result back is the filter that behaves like the coverage mask's own.
407
+ *
408
+ * The stored value keeps meaning "distance in level-0 texels, range `range`", which is what the
409
+ * shader's decode assumes once it scales its range by the level it sampled.
410
+ *
411
+ * `distanceChannels` says which channels this applies to, since a texel may mix the two.
412
+ */
413
+ export function downsampleDistancePage(data, size, range, distanceChannels) {
414
+ const isDistance = [false, false, false, false];
415
+ for (const channel of distanceChannels)
416
+ isDistance[channel] = true;
417
+ const half = Math.max(1, size >> 1);
418
+ const result = new Uint8Array(half * half * 4);
419
+ for (let y = 0; y < half; y++) {
420
+ for (let x = 0; x < half; x++) {
421
+ const sums = [0, 0, 0, 0];
422
+ for (let dy = 0; dy < 2; dy++) {
423
+ const sourceY = Math.min(y * 2 + dy, size - 1);
424
+ for (let dx = 0; dx < 2; dx++) {
425
+ const sourceX = Math.min(x * 2 + dx, size - 1);
426
+ const index = (sourceY * size + sourceX) * 4;
427
+ for (let channel = 0; channel < 4; channel++) {
428
+ const value = data[index + channel];
429
+ sums[channel] += isDistance[channel] ? readCoverage(value, range) : value;
430
+ }
431
+ }
432
+ }
433
+ const target = (y * half + x) * 4;
434
+ for (let channel = 0; channel < 4; channel++) {
435
+ const mean = sums[channel] / 4;
436
+ result[target + channel] = isDistance[channel] ? encodeCoverage(mean, range) : Math.round(mean);
437
+ }
438
+ }
439
+ }
440
+ return result;
441
+ }
442
+ /**
443
+ * Builds a square page's mip chain, stopping early at 1×1 or once `levels` are reached.
444
+ *
445
+ * `downsample` defaults to the coverage box filter. A distance-field page passes
446
+ * `downsampleDistancePage` instead, so its levels keep reporting coverage rather than an average
447
+ * distance.
448
+ */
449
+ export function buildMipChain(data, size, levels, downsample = downsamplePage) {
450
+ const chain = [{ size, data }];
451
+ let current = data;
452
+ let currentSize = size;
453
+ while (chain.length < levels && currentSize > 1) {
454
+ current = downsample(current, currentSize);
455
+ currentSize = Math.max(1, currentSize >> 1);
456
+ chain.push({ size: currentSize, data: current });
457
+ }
458
+ return chain;
459
+ }
460
+ /**
461
+ * Extracts one rectangle of a page and pads its rows to WebGPU's 256 byte `bytesPerRow`
462
+ * requirement, which only binds once a rectangle has more than one row. The copy is a few hundred
463
+ * bytes for a single glyph, so it is not worth avoiding.
464
+ */
465
+ export function extractRegion(data, size, rect) {
466
+ const rowBytes = rect.width * 4;
467
+ const stride = Math.ceil(rowBytes / 256) * 256;
468
+ // A full-width rectangle whose rows already meet the alignment needs no copy at all.
469
+ if (rect.x === 0 && rect.width === size && stride === rowBytes) {
470
+ const start = rect.y * rowBytes;
471
+ return { data: data.subarray(start, start + rowBytes * rect.height), bytesPerRow: stride };
472
+ }
473
+ const padded = new Uint8Array(stride * rect.height);
474
+ for (let row = 0; row < rect.height; row++) {
475
+ const from = ((rect.y + row) * size + rect.x) * 4;
476
+ padded.set(data.subarray(from, from + rowBytes), row * stride);
477
+ }
478
+ return { data: padded, bytesPerRow: stride };
479
+ }
480
+ /** Pads one whole mip level's rows through `extractRegion`. */
481
+ export function padMipRows(data, size) {
482
+ return extractRegion(data, size, { x: 0, y: 0, width: size, height: size });
483
+ }
484
+ function packInto(packing, w, h, size) {
485
+ for (const shelf of packing.shelves) {
486
+ if (shelf.height >= h && shelf.used + w <= size) {
487
+ const x = shelf.used;
488
+ shelf.used += w;
489
+ return { x, y: shelf.y };
490
+ }
491
+ }
492
+ if (packing.nextY + h > size)
493
+ return undefined;
494
+ const y = packing.nextY;
495
+ packing.shelves.push({ y, height: h, used: w });
496
+ packing.nextY += h;
497
+ return { x: 0, y };
498
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Browser-side font loading and glyph rasterisation for the text renderer.
3
+ *
4
+ * reference rasterises glyphs with swash into a `FontAtlas`
5
+ * (`the reference implementation`); the equivalent rasteriser in a browser is
6
+ * Canvas2D, so `fillText` plays the role of `swash::scale::Render` and the browser's font
7
+ * stack stands in for font discovery. The bundled font is inlined exactly the way reference
8
+ * inlines `FiraMono-subset.ttf` with `include_bytes!`, so nothing is fetched at runtime.
9
+ *
10
+ * The subset deliberately has no CJK coverage; pass a different family through the text
11
+ * options when those are needed.
12
+ *
13
+ * Everything here is browser-only. Layout depends on the `TextMeasurer` interface alone, so
14
+ * unit tests keep running in plain Node, and the GPU tests inject their own rasteriser.
15
+ */
16
+ import { type FontFace, type GlyphMetrics, type TextMeasurer, type VerticalMetrics } from "./text-layout.js";
17
+ /**
18
+ * How a glyph is stored in the atlas, and therefore how the shader has to decode it.
19
+ *
20
+ * - `bitmap` keeps the anti-aliased coverage reference `FontAtlas` uses: RGB 255, coverage in alpha.
21
+ * What was rasterised is what is drawn, and no amount of scaling can recover more edge detail.
22
+ * - `sdf` stores a signed distance in red. One channel means sharp corners round off, but the
23
+ * fragment stage rebuilds the edge from the distance, so it stays sharp at any magnification.
24
+ * - `msdf` stores a per-edge signed distance in RGB. The extra channels preserve corners, at the
25
+ * cost of three times the atlas area and a median — rather than a single — channel lookup.
26
+ *
27
+ * Both distance encodings put the *true* distance to the shape in alpha (msdfgen's `mtsdf` layout),
28
+ * which is what a block's outline reads: the median that preserves a corner cannot also place a
29
+ * ring around it. `bitmap` has no distance to give, so its outline is sampled instead.
30
+ */
31
+ export type GlyphEncoding = "bitmap" | "sdf" | "msdf";
32
+ /**
33
+ * Family name the bundled font is registered under. It is deliberately not `"Fira Mono"` so
34
+ * a locally installed face can never shadow the inlined one.
35
+ */
36
+ export declare const DEFAULT_FONT_FAMILY = "vgpu-render-fira-mono";
37
+ /** Decodes a base64 payload into bytes. Shared by the inlined font and the prebaked atlas. */
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>;
56
+ /** Decodes the inlined WOFF2 bytes of the bundled font, which is what `FontFace` takes. */
57
+ export declare function decodeEmbeddedFont(): Uint8Array<ArrayBuffer>;
58
+ /**
59
+ * Decodes the inlined TrueType bytes of the same subset. The outline-based encoders need this one:
60
+ * `opentype.js` reads glyph outlines, and it cannot decompress the WOFF2 container.
61
+ */
62
+ export declare function decodeEmbeddedTrueTypeFont(): Uint8Array<ArrayBuffer>;
63
+ /**
64
+ * Registers the bundled font with the document, once per page. Measuring or drawing with
65
+ * `DEFAULT_FONT_FAMILY` before this resolves falls back to another face, so await it before
66
+ * the first frame that draws text.
67
+ */
68
+ export declare function loadDefaultFont(): Promise<void>;
69
+ /** One rasterised character: the texels to pack plus the placement swash would have reported. */
70
+ export type RasterizedGlyph = {
71
+ readonly width: number;
72
+ readonly height: number;
73
+ /**
74
+ * Tightly packed RGBA8 texels, `width * height * 4` bytes.
75
+ *
76
+ * The layout is the encoding's business rather than the atlas': the bitmap path writes a
77
+ * tintable alpha mask, and the distance-field paths write distances into the channels a shader
78
+ * knows how to decode. Keeping the encoding here means the atlas only packs and copies texels,
79
+ * which is what lets prebaked and on-demand glyph sources share one atlas.
80
+ */
81
+ readonly texels: Uint8Array;
82
+ /**
83
+ * The ink box inside those texels, which is up to a texel smaller than the image: the box is
84
+ * rounded up so no edge coverage is lost, and the atlas has to map only the ink part. Mapping the
85
+ * whole image would rescale the glyph by that fraction — invisible for interpolated coverage, but
86
+ * a visible slimming of every stem once a distance field reconstructs the edge.
87
+ */
88
+ readonly inkWidth: number;
89
+ readonly inkHeight: number;
90
+ readonly bearingLeft: number;
91
+ readonly bearingTop: number;
92
+ readonly advance: number;
93
+ };
94
+ /** Rasterises characters for the glyph atlas. */
95
+ export interface GlyphRasterizer {
96
+ /** The encoding every glyph this source returns is written in. */
97
+ readonly encoding: GlyphEncoding;
98
+ /**
99
+ * Distance in atlas texels that a distance-field encoding covers either side of the edge, which
100
+ * the shader needs in order to decode it. Unused by `bitmap`.
101
+ */
102
+ readonly distanceRange?: number;
103
+ /**
104
+ * Default rasterisation scale for this source: a request that names no scale of its own is
105
+ * rasterised at `fontSize × rasterScale` texels while layout and quads stay in font pixels.
106
+ * Larger costs atlas area quadratically and keeps a magnified block from showing the texel grid
107
+ * its distance field was reconstructed from.
108
+ *
109
+ * It belongs to the source rather than to the renderer because two sources of the same encoding
110
+ * can coexist in one frame — which is exactly what a side-by-side quality comparison needs. The
111
+ * renderer overrides it per block with the size the block occupies on screen; see `text-raster`.
112
+ */
113
+ readonly rasterScale?: number;
114
+ /**
115
+ * Whether the source can rasterise at whatever size the renderer asks for.
116
+ *
117
+ * A source that reads outlines or a Canvas2D face can, so the renderer hands it the size the block
118
+ * covers on screen and the glyph stays sharp at every font size. A prebaked page cannot: its glyphs
119
+ * were rasterised once at a fixed size, so the renderer leaves it on `rasterScale` instead.
120
+ */
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;
131
+ /**
132
+ * Returns `undefined` for characters with no ink, such as whitespace.
133
+ *
134
+ * `rasterSize` is the size in texels the glyph is rasterised at, which is *not* the layout font
135
+ * size: the atlas asks for the size the glyph will cover on screen, so the same character stays
136
+ * sharp at any font size while still being rasterised once per size rung.
137
+ */
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;
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;
157
+ /** Encodings Canvas2D coverage can produce directly. `msdf` needs outlines, not a mask. */
158
+ export type CoverageEncoding = Exclude<GlyphEncoding, "msdf">;
159
+ /**
160
+ * Canvas2D glyph source, doubling as the layout `TextMeasurer` so the canvas is only set up
161
+ * once per canvas-configured face and every `measureText` result is cached.
162
+ *
163
+ * Metrics follow Canvas2D's convention: the pen sits at the origin and the baseline is y = 0
164
+ * with y growing downwards. `actualBoundingBoxLeft` is a *leftward* distance, so a glyph that
165
+ * starts right of the pen reports a negative value — negating it yields the ink's left edge
166
+ * in pen space, which is what the atlas stores.
167
+ */
168
+ export declare class CanvasGlyphRasterizer implements GlyphRasterizer, TextMeasurer {
169
+ #private;
170
+ readonly encoding: CoverageEncoding;
171
+ readonly distanceRange: number;
172
+ readonly rasterScale: number;
173
+ /**
174
+ * Whether the renderer drives this source's raster size from the size a block covers on screen.
175
+ * A Canvas2D face can be asked for any size, so it defaults to `true`; a source that needs a fixed
176
+ * raster — a pixel-accurate comparison against another renderer, say — turns it off.
177
+ */
178
+ readonly dynamicRaster: boolean;
179
+ constructor(options?: {
180
+ encoding?: CoverageEncoding;
181
+ distanceRange?: number;
182
+ rasterScale?: number;
183
+ dynamicRaster?: boolean;
184
+ });
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;
188
+ }
189
+ /** Packs Canvas2D coverage into the RGBA layout the chosen encoding expects. */
190
+ export declare function encodeCoverage(coverage: Uint8Array, width: number, height: number, encoding: CoverageEncoding, range: number): Uint8Array;