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