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,159 @@
1
+ /**
2
+ * Outline-backed glyph source, using `opentype.js` to read the bundled TrueType face.
3
+ *
4
+ * Canvas2D can only hand back a coverage mask, and a mask has lost the edges that a multi-channel
5
+ * distance field is built from. Outlines are where those edges still exist, so this source parses
6
+ * the inlined subset and encodes each glyph with `msdfFromOutline`.
7
+ *
8
+ * It is the on-demand counterpart to a prebaked atlas: it works for any character the face covers
9
+ * and any face at all, at the cost of running the encoder on the first frame each glyph appears.
10
+ * Coordinates follow the Canvas2D convention the rest of the text stack uses — x to the right, y
11
+ * down, the pen at (0, 0) and the baseline at y = 0 — which is also what `glyph.getPath` produces.
12
+ */
13
+ import * as opentype from "opentype.js";
14
+ import { decodeEmbeddedTrueTypeFont } from "./text-font.js";
15
+ import { msdfFromOutline } from "./text-msdf.js";
16
+ import { DEFAULT_SDF_RANGE } from "./text-sdf.js";
17
+ /** Turns a path's command list into outline segments, offset so the ink box starts at the origin. */
18
+ export function commandsToSegments(commands, offsetX, offsetY) {
19
+ const segments = [];
20
+ let current;
21
+ let subpathStart;
22
+ for (const command of commands) {
23
+ const to = command.type === "Z" ? undefined : [command.x - offsetX, command.y - offsetY];
24
+ switch (command.type) {
25
+ case "M":
26
+ current = to;
27
+ subpathStart = to;
28
+ break;
29
+ case "L":
30
+ if (current !== undefined && to !== undefined)
31
+ segments.push({ from: current, to });
32
+ current = to;
33
+ break;
34
+ case "Q":
35
+ if (current !== undefined && to !== undefined) {
36
+ segments.push({ from: current, control1: [command.x1 - offsetX, command.y1 - offsetY], to });
37
+ }
38
+ current = to;
39
+ break;
40
+ case "C":
41
+ if (current !== undefined && to !== undefined) {
42
+ segments.push({
43
+ from: current,
44
+ control1: [command.x1 - offsetX, command.y1 - offsetY],
45
+ control2: [command.x2 - offsetX, command.y2 - offsetY],
46
+ to,
47
+ });
48
+ }
49
+ current = to;
50
+ break;
51
+ case "Z":
52
+ // An explicit closing edge only if the contour did not already return to its start.
53
+ if (current !== undefined && subpathStart !== undefined) {
54
+ const [cx, cy] = current;
55
+ const [sx, sy] = subpathStart;
56
+ if (cx !== sx || cy !== sy)
57
+ segments.push({ from: current, to: subpathStart });
58
+ current = subpathStart;
59
+ }
60
+ break;
61
+ }
62
+ }
63
+ return segments;
64
+ }
65
+ /**
66
+ * Resolves `parse` from either build of `opentype.js`.
67
+ *
68
+ * The package ships a CommonJS entry and an ESM entry without an `exports` map, so Node's ESM
69
+ * loader only exposes the CommonJS default export while bundlers pick the ESM module and its named
70
+ * exports. Reading whichever shape we were handed keeps both paths working.
71
+ */
72
+ const parseFont = (opentype.parse ??
73
+ opentype.default.parse);
74
+ export class OutlineGlyphRasterizer {
75
+ encoding = "msdf";
76
+ distanceRange;
77
+ rasterScale;
78
+ /** Whether the renderer drives this source's raster per block. See `dynamicRaster` in the options. */
79
+ dynamicRaster;
80
+ #font;
81
+ #metrics = new Map();
82
+ #vertical = new Map();
83
+ constructor(options = {}) {
84
+ this.#font = parseFont(options.font ?? decodeEmbeddedTrueTypeFont().buffer);
85
+ this.distanceRange = options.distanceRange ?? DEFAULT_SDF_RANGE;
86
+ this.rasterScale = options.rasterScale ?? 1;
87
+ this.dynamicRaster = options.dynamicRaster ?? true;
88
+ }
89
+ /** The face this source reads, for callers that want to name it. */
90
+ get font() {
91
+ return this.#font;
92
+ }
93
+ #scale(fontSize) {
94
+ return fontSize / this.#font.unitsPerEm;
95
+ }
96
+ /** Ink box of one character at `fontSize`, in the pen's own coordinates. */
97
+ #inkBox(character, fontSize) {
98
+ const path = this.#font.charToGlyph(character).getPath(0, 0, fontSize);
99
+ if (path.commands.length === 0)
100
+ return { left: 0, top: 0, width: 0, height: 0 };
101
+ const box = path.getBoundingBox();
102
+ const left = Number.isFinite(box.x1) ? box.x1 : 0;
103
+ const top = Number.isFinite(box.y1) ? box.y1 : 0;
104
+ const right = Number.isFinite(box.x2) ? box.x2 : left;
105
+ const bottom = Number.isFinite(box.y2) ? box.y2 : top;
106
+ return { left, top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) };
107
+ }
108
+ /**
109
+ * This source holds one parsed buffer, so a weight, width or slant it was not built with cannot
110
+ * change the outlines it returns. Keying on the family alone keeps the atlas from caching the same
111
+ * glyph once per axis value.
112
+ */
113
+ faceKey = (face) => face.family;
114
+ verticalMetrics(_face, fontSize) {
115
+ const cached = this.#vertical.get(String(fontSize));
116
+ if (cached !== undefined)
117
+ return cached;
118
+ const scale = this.#scale(fontSize);
119
+ const value = { ascent: this.#font.ascender * scale, descent: -this.#font.descender * scale };
120
+ this.#vertical.set(String(fontSize), value);
121
+ return value;
122
+ }
123
+ glyphMetrics(character, _face, fontSize) {
124
+ const key = `${fontSize}|${character}`;
125
+ const cached = this.#metrics.get(key);
126
+ if (cached !== undefined)
127
+ return cached;
128
+ const ink = this.#inkBox(character, fontSize);
129
+ const advance = (this.#font.charToGlyph(character).advanceWidth ?? 0) * this.#scale(fontSize);
130
+ const value = {
131
+ advance,
132
+ width: ink.width,
133
+ height: ink.height,
134
+ bearingLeft: ink.left,
135
+ bearingTop: ink.top,
136
+ };
137
+ this.#metrics.set(key, value);
138
+ return value;
139
+ }
140
+ rasterize(character, _face, rasterSize) {
141
+ const ink = this.#inkBox(character, rasterSize);
142
+ if (!(ink.width > 0) || !(ink.height > 0))
143
+ return undefined;
144
+ const width = Math.max(1, Math.ceil(ink.width));
145
+ const height = Math.max(1, Math.ceil(ink.height));
146
+ const path = this.#font.charToGlyph(character).getPath(0, 0, rasterSize);
147
+ const segments = commandsToSegments(path.commands, ink.left, ink.top);
148
+ return {
149
+ width,
150
+ height,
151
+ texels: msdfFromOutline(segments, width, height, this.distanceRange),
152
+ inkWidth: ink.width,
153
+ inkHeight: ink.height,
154
+ bearingLeft: ink.left,
155
+ bearingTop: ink.top,
156
+ advance: (this.#font.charToGlyph(character).advanceWidth ?? 0) * this.#scale(rasterSize),
157
+ };
158
+ }
159
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Multi-channel signed distance field generation from glyph outlines.
3
+ *
4
+ * A single distance channel stores one number per texel: how far the nearest edge is. That is
5
+ * enough everywhere except near a corner, where two edges meet and the single value can only
6
+ * describe one of them, so the corner rounds off when it is rebuilt. MSDF stores a distance per
7
+ * *edge group* instead — three of them, in RGB — and the shader takes the median. At a corner the
8
+ * three channels disagree and the median keeps the one that is still nearly correct, which is what
9
+ * preserves the point.
10
+ *
11
+ * Curves are chopped into short straight pieces before distances are measured: exact distances to a
12
+ * bezier would need a numerical solve per texel, while a chord error under half a texel is invisible
13
+ * at the sizes this is used at. Edge group assignment follows msdfgen's rule of thumb — adjacent
14
+ * edges that run nearly straight through their shared vertex keep the same group, everything else
15
+ * advances around a three-colour wheel.
16
+ *
17
+ * The median only equals the distance to the *nearest* edge where at least two channels are
18
+ * saturated. Inside a stroke that sees two or three edges it resolves to the middle distance
19
+ * instead, and the rebuilt edge drifts by up to half a texel: measured against a super-sampled
20
+ * coverage reference at 24 px, the raw field is off by 0.073 (mean) and 0.513 (worst texel) in alpha
21
+ * units. msdfgen handles that with an error-correction pass after generating the field, whose
22
+ * flagship form equalises every flagged texel to its own median. That was measured here too and it
23
+ * made the reconstruction *worse* (0.078), because this encoder gives every channel the texel's own
24
+ * inside/outside sign rather than a per-edge signed distance, so the channels differ only in which
25
+ * edge is nearest and equalising them flattens the gradient the filter relies on.
26
+ *
27
+ * What does help is correcting the median itself against the exact shape distance: only the channel
28
+ * or channels needed to make that distance the middle value move, so the other channel keeps its
29
+ * gradient. The same measurement drops to 0.043, against 0.040 for a field whose whole edge band is
30
+ * made exactly right — the remainder is the resolution limit of one distance per texel plus linear
31
+ * filtering, not something another pass can recover.
32
+ *
33
+ * Alpha is not part of the median: it keeps the same exact shape distance uncorrected, which is what
34
+ * an outline of uniform width needs and what the median cannot provide at a corner.
35
+ */
36
+ export type Point = readonly [number, number];
37
+ /** One outline segment: a straight line, or a curve carrying its control points. */
38
+ export type OutlineSegment = {
39
+ readonly from: Point;
40
+ readonly control1?: Point;
41
+ readonly control2?: Point;
42
+ readonly to: Point;
43
+ };
44
+ type ColoredLine = {
45
+ readonly from: Point;
46
+ readonly to: Point;
47
+ readonly channel: 0 | 1 | 2;
48
+ };
49
+ /**
50
+ * Assigns each segment to one of three channels. Segments that continue the same edge share a
51
+ * channel; a join that actually turns the outline advances to the next one, so the three edges of a
52
+ * corner end up on three different channels.
53
+ */
54
+ export declare function colorOutlineEdges(segments: readonly OutlineSegment[]): Uint8Array;
55
+ /** Chops curves into straight pieces, each inheriting its parent's channel. */
56
+ export declare function flattenOutline(segments: readonly OutlineSegment[], channels: Uint8Array, steps?: number): readonly ColoredLine[];
57
+ /**
58
+ * Renders one glyph outline into an RGBA8 distance field.
59
+ *
60
+ * Each channel holds the signed distance to the nearest edge in its group, averaged over the texel
61
+ * centre, and encoded as `distance / (2 × range) + 0.5` so 128 is the edge. Channels no edge was
62
+ * assigned to saturate, because a median over two real distances and one saturated value still
63
+ * resolves to a real edge. Alpha carries the *true* distance to the shape, which is msdfgen's
64
+ * `mtsdf` layout: the median in RGB preserves the corners a single channel rounds off, while a
65
+ * uniform-width outline reads alpha, because a per-edge median dents exactly at a corner. The field
66
+ * is then corrected against the exact shape distance — see the error-correction note at the top of
67
+ * this file.
68
+ */
69
+ export declare function msdfFromOutline(segments: readonly OutlineSegment[], width: number, height: number, range: number, steps?: number): Uint8Array;
70
+ export {};
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Multi-channel signed distance field generation from glyph outlines.
3
+ *
4
+ * A single distance channel stores one number per texel: how far the nearest edge is. That is
5
+ * enough everywhere except near a corner, where two edges meet and the single value can only
6
+ * describe one of them, so the corner rounds off when it is rebuilt. MSDF stores a distance per
7
+ * *edge group* instead — three of them, in RGB — and the shader takes the median. At a corner the
8
+ * three channels disagree and the median keeps the one that is still nearly correct, which is what
9
+ * preserves the point.
10
+ *
11
+ * Curves are chopped into short straight pieces before distances are measured: exact distances to a
12
+ * bezier would need a numerical solve per texel, while a chord error under half a texel is invisible
13
+ * at the sizes this is used at. Edge group assignment follows msdfgen's rule of thumb — adjacent
14
+ * edges that run nearly straight through their shared vertex keep the same group, everything else
15
+ * advances around a three-colour wheel.
16
+ *
17
+ * The median only equals the distance to the *nearest* edge where at least two channels are
18
+ * saturated. Inside a stroke that sees two or three edges it resolves to the middle distance
19
+ * instead, and the rebuilt edge drifts by up to half a texel: measured against a super-sampled
20
+ * coverage reference at 24 px, the raw field is off by 0.073 (mean) and 0.513 (worst texel) in alpha
21
+ * units. msdfgen handles that with an error-correction pass after generating the field, whose
22
+ * flagship form equalises every flagged texel to its own median. That was measured here too and it
23
+ * made the reconstruction *worse* (0.078), because this encoder gives every channel the texel's own
24
+ * inside/outside sign rather than a per-edge signed distance, so the channels differ only in which
25
+ * edge is nearest and equalising them flattens the gradient the filter relies on.
26
+ *
27
+ * What does help is correcting the median itself against the exact shape distance: only the channel
28
+ * or channels needed to make that distance the middle value move, so the other channel keeps its
29
+ * gradient. The same measurement drops to 0.043, against 0.040 for a field whose whole edge band is
30
+ * made exactly right — the remainder is the resolution limit of one distance per texel plus linear
31
+ * filtering, not something another pass can recover.
32
+ *
33
+ * Alpha is not part of the median: it keeps the same exact shape distance uncorrected, which is what
34
+ * an outline of uniform width needs and what the median cannot provide at a corner.
35
+ */
36
+ /** Straight pieces a curve is chopped into. Eight keeps the chord error well under a texel. */
37
+ const CURVE_STEPS = 8;
38
+ /** Directions closer than this count as one continuous edge, so they share a channel. */
39
+ const COLLINEAR_COSINE = 0.9986;
40
+ function unit(dx, dy) {
41
+ const length = Math.hypot(dx, dy);
42
+ return length === 0 ? [0, 0] : [dx / length, dy / length];
43
+ }
44
+ /** Tangent leaving a segment's start, which is where its colour has to agree with the previous one. */
45
+ function outgoingDirection(segment) {
46
+ const reference = segment.control1 ?? segment.to;
47
+ return unit(reference[0] - segment.from[0], reference[1] - segment.from[1]);
48
+ }
49
+ /** Tangent arriving at a segment's end, the other half of the join test. */
50
+ function incomingDirection(segment) {
51
+ const reference = segment.control2 ?? segment.control1 ?? segment.from;
52
+ return unit(segment.to[0] - reference[0], segment.to[1] - reference[1]);
53
+ }
54
+ function continuesEdge(previous, next) {
55
+ const incoming = incomingDirection(previous);
56
+ const outgoing = outgoingDirection(next);
57
+ return incoming[0] * outgoing[0] + incoming[1] * outgoing[1] >= COLLINEAR_COSINE;
58
+ }
59
+ /**
60
+ * Assigns each segment to one of three channels. Segments that continue the same edge share a
61
+ * channel; a join that actually turns the outline advances to the next one, so the three edges of a
62
+ * corner end up on three different channels.
63
+ */
64
+ export function colorOutlineEdges(segments) {
65
+ const channels = new Uint8Array(segments.length);
66
+ let channel = 0;
67
+ for (let index = 0; index < segments.length; index++) {
68
+ if (index > 0 && !continuesEdge(segments[index - 1], segments[index]))
69
+ channel = (channel + 1) % 3;
70
+ channels[index] = channel;
71
+ }
72
+ return channels;
73
+ }
74
+ function evaluate(segment, t) {
75
+ const { from, to, control1, control2 } = segment;
76
+ const u = 1 - t;
77
+ if (control1 === undefined)
78
+ return to;
79
+ if (control2 === undefined) {
80
+ return [
81
+ u * u * from[0] + 2 * u * t * control1[0] + t * t * to[0],
82
+ u * u * from[1] + 2 * u * t * control1[1] + t * t * to[1],
83
+ ];
84
+ }
85
+ return [
86
+ u * u * u * from[0] + 3 * u * u * t * control1[0] + 3 * u * t * t * control2[0] + t * t * t * to[0],
87
+ u * u * u * from[1] + 3 * u * u * t * control1[1] + 3 * u * t * t * control2[1] + t * t * t * to[1],
88
+ ];
89
+ }
90
+ /** Chops curves into straight pieces, each inheriting its parent's channel. */
91
+ export function flattenOutline(segments, channels, steps = CURVE_STEPS) {
92
+ const lines = [];
93
+ for (let index = 0; index < segments.length; index++) {
94
+ const segment = segments[index];
95
+ const channel = ((channels[index] ?? 0) % 3);
96
+ if (segment.control1 === undefined) {
97
+ lines.push({ from: segment.from, to: segment.to, channel });
98
+ continue;
99
+ }
100
+ for (let step = 0; step < steps; step++) {
101
+ lines.push({ from: evaluate(segment, step / steps), to: evaluate(segment, (step + 1) / steps), channel });
102
+ }
103
+ }
104
+ return lines;
105
+ }
106
+ /** Even-odd containment: a horizontal ray from the point, counting crossings to its right. */
107
+ function isInside(px, py, lines) {
108
+ let inside = false;
109
+ for (const line of lines) {
110
+ const [ax, ay] = line.from;
111
+ const [bx, by] = line.to;
112
+ if (ay > py !== by > py) {
113
+ const crossing = ax + ((py - ay) / (by - ay)) * (bx - ax);
114
+ if (px < crossing)
115
+ inside = !inside;
116
+ }
117
+ }
118
+ return inside;
119
+ }
120
+ function distanceToLine(px, py, line) {
121
+ const [ax, ay] = line.from;
122
+ const [bx, by] = line.to;
123
+ const dx = bx - ax;
124
+ const dy = by - ay;
125
+ const lengthSquared = dx * dx + dy * dy;
126
+ if (lengthSquared === 0)
127
+ return Math.hypot(px - ax, py - ay);
128
+ const projected = ((px - ax) * dx + (py - ay) * dy) / lengthSquared;
129
+ const t = projected < 0 ? 0 : projected > 1 ? 1 : projected;
130
+ return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
131
+ }
132
+ /** Encodes one distance as `distance / (2 × range) + 0.5`, saturated to an 8-bit channel. */
133
+ function encodeDistance(value, range) {
134
+ const encoded = Math.round((value / (2 * range) + 0.5) * 255);
135
+ return encoded < 0 ? 0 : encoded > 255 ? 255 : encoded;
136
+ }
137
+ /** Indices of three channel values in ascending order. */
138
+ function ascending(values) {
139
+ const order = [0, 1, 2];
140
+ if (values[order[0]] > values[order[1]])
141
+ [order[0], order[1]] = [order[1], order[0]];
142
+ if (values[order[1]] > values[order[2]])
143
+ [order[1], order[2]] = [order[2], order[1]];
144
+ if (values[order[0]] > values[order[1]])
145
+ [order[0], order[1]] = [order[1], order[0]];
146
+ return order;
147
+ }
148
+ /**
149
+ * How far a texel's median may sit from the exact shape distance before it is corrected, in texels.
150
+ * Half a texel is the point where the reconstruction visibly shifts the edge.
151
+ */
152
+ const CORRECTION_TOLERANCE = 0.5;
153
+ /**
154
+ * Moves each texel's median onto the exact shape distance, in place.
155
+ *
156
+ * Only the channel or channels required to make that distance the middle value are written; the
157
+ * remaining channel keeps its own gradient, which is what stops a magnified edge from flattening
158
+ * across the corrected texel. Beyond the encoded band nothing is touched: there the coverage ramp
159
+ * is already saturated, so the median's exact value cannot move a pixel.
160
+ */
161
+ function correctMedians(texels, distances, range) {
162
+ const tolerance = (CORRECTION_TOLERANCE / (2 * range)) * 255;
163
+ for (let index = 0; index < distances.length; index++) {
164
+ const shape = distances[index];
165
+ if (!(Math.abs(shape) <= range))
166
+ continue;
167
+ const base = index * 4;
168
+ const red = texels[base];
169
+ const green = texels[base + 1];
170
+ const blue = texels[base + 2];
171
+ const order = ascending([red, green, blue]);
172
+ const values = [red, green, blue];
173
+ const lowest = values[order[0]];
174
+ const median = values[order[1]];
175
+ const highest = values[order[2]];
176
+ const target = encodeDistance(shape, range);
177
+ if (Math.abs(median - target) <= tolerance)
178
+ continue;
179
+ if (target < median) {
180
+ // Lower the highest channel. Once the target is at or below the lowest one, that channel has
181
+ // to move as well, or it would become the new median instead.
182
+ texels[base + order[2]] = target;
183
+ if (target <= lowest)
184
+ texels[base + order[0]] = target;
185
+ }
186
+ else {
187
+ texels[base + order[0]] = target;
188
+ if (target >= highest)
189
+ texels[base + order[2]] = target;
190
+ }
191
+ }
192
+ }
193
+ /**
194
+ * Renders one glyph outline into an RGBA8 distance field.
195
+ *
196
+ * Each channel holds the signed distance to the nearest edge in its group, averaged over the texel
197
+ * centre, and encoded as `distance / (2 × range) + 0.5` so 128 is the edge. Channels no edge was
198
+ * assigned to saturate, because a median over two real distances and one saturated value still
199
+ * resolves to a real edge. Alpha carries the *true* distance to the shape, which is msdfgen's
200
+ * `mtsdf` layout: the median in RGB preserves the corners a single channel rounds off, while a
201
+ * uniform-width outline reads alpha, because a per-edge median dents exactly at a corner. The field
202
+ * is then corrected against the exact shape distance — see the error-correction note at the top of
203
+ * this file.
204
+ */
205
+ export function msdfFromOutline(segments, width, height, range, steps = CURVE_STEPS) {
206
+ if (!(range > 0))
207
+ throw new RangeError("msdf range must be greater than zero");
208
+ const lines = flattenOutline(segments, colorOutlineEdges(segments), steps);
209
+ const texels = new Uint8Array(width * height * 4);
210
+ const distances = new Float64Array(width * height);
211
+ for (let y = 0; y < height; y++) {
212
+ for (let x = 0; x < width; x++) {
213
+ const px = x + 0.5;
214
+ const py = y + 0.5;
215
+ const inside = isInside(px, py, lines);
216
+ const nearest = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY];
217
+ const signed = [0, 0, 0];
218
+ let shapeDistance = Number.POSITIVE_INFINITY;
219
+ for (const line of lines) {
220
+ const distance = distanceToLine(px, py, line);
221
+ if (distance < shapeDistance)
222
+ shapeDistance = distance;
223
+ if (distance < nearest[line.channel]) {
224
+ nearest[line.channel] = distance;
225
+ signed[line.channel] = inside ? distance : -distance;
226
+ }
227
+ }
228
+ const index = y * width + x;
229
+ const target = index * 4;
230
+ for (let channel = 0; channel < 3; channel++) {
231
+ const value = nearest[channel] === Number.POSITIVE_INFINITY ? (inside ? range : -range) : signed[channel];
232
+ texels[target + channel] = encodeDistance(value, range);
233
+ }
234
+ const shape = inside ? shapeDistance : -shapeDistance;
235
+ // Alpha holds the true distance, so the shader can draw an outline that keeps its width.
236
+ texels[target + 3] = encodeDistance(shape, range);
237
+ distances[index] = shape;
238
+ }
239
+ }
240
+ correctMedians(texels, distances, range);
241
+ return texels;
242
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Raster-size selection for the on-demand glyph atlas.
3
+ *
4
+ * reference keys a `FontAtlas` by font size and rasterises each size at the physical pixel size it will be
5
+ * drawn at, so every font size comes out sharp and every font size pays for its own atlas. A debug
6
+ * draw frame cannot afford that shape: its text changes size continuously (a world block follows the
7
+ * camera) and a CJK glyph set is large, so one atlas per size would re-rasterise the same character
8
+ * over and over and grow without any eviction.
9
+ *
10
+ * The fix is the one browsers use for their glyph cache: rasterise at the *screen* size the glyph
11
+ * will occupy, quantised onto a fixed ladder of sizes. A size between two rungs takes the rung above
12
+ * it — at most `step`× oversampled, which the mip chain covers when the block is drawn smaller — so
13
+ * the glyphs the atlas holds stay bounded while the size keeps moving.
14
+ */
15
+ /** Ratio between neighbouring rungs. 1.25 keeps the worst-case oversampling at a quarter of a texel. */
16
+ export declare const DEFAULT_RASTER_STEP = 1.25;
17
+ /** Smallest and largest raster rungs, in texels along the font size. Below `MIN_RASTER_PX` a stem is
18
+ * thinner than a texel; above `MAX_RASTER_PX` a glyph costs more atlas area than it can pay for. */
19
+ export declare const MIN_RASTER_PX = 6;
20
+ export declare const MAX_RASTER_PX = 256;
21
+ /**
22
+ * The smallest raster size on the ladder that covers `screenPx`.
23
+ *
24
+ * Rounding up rather than to the nearest rung is what keeps the atlas an upper bound on what the
25
+ * screen asks for: a glyph is never magnified when it is decoded, only minified through the mip
26
+ * chain, which is the direction that still looks sharp.
27
+ */
28
+ export declare function quantizeRasterPx(screenPx: number, step?: number, min?: number, max?: number): number;
29
+ /** The raster scale a glyph of `fontSize` layout pixels needs in order to land on `rasterPx` texels. */
30
+ export declare function rasterScaleFor(rasterPx: number, fontSize: number): number;
31
+ /**
32
+ * Screen pixels one world unit covers at `point`, for the perspective and orthographic matrices the
33
+ * camera writes.
34
+ *
35
+ * This is the same estimate the world-width shaders use (`viewProjection[1][1] * viewportSize.y *
36
+ * 0.5 / clip.w`), so a raster size picked here agrees with the width the shaders compute for the
37
+ * block. It ignores how the block's own plane is angled to the camera, which only matters for the
38
+ * rung chosen; `quantizeRasterPx` absorbs a miss of one rung by oversampling.
39
+ */
40
+ export declare function screenPixelsPerWorldUnit(viewProjection: ArrayLike<number>, point: readonly [number, number, number], viewportHeight: number): number;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Raster-size selection for the on-demand glyph atlas.
3
+ *
4
+ * reference keys a `FontAtlas` by font size and rasterises each size at the physical pixel size it will be
5
+ * drawn at, so every font size comes out sharp and every font size pays for its own atlas. A debug
6
+ * draw frame cannot afford that shape: its text changes size continuously (a world block follows the
7
+ * camera) and a CJK glyph set is large, so one atlas per size would re-rasterise the same character
8
+ * over and over and grow without any eviction.
9
+ *
10
+ * The fix is the one browsers use for their glyph cache: rasterise at the *screen* size the glyph
11
+ * will occupy, quantised onto a fixed ladder of sizes. A size between two rungs takes the rung above
12
+ * it — at most `step`× oversampled, which the mip chain covers when the block is drawn smaller — so
13
+ * the glyphs the atlas holds stay bounded while the size keeps moving.
14
+ */
15
+ /** Ratio between neighbouring rungs. 1.25 keeps the worst-case oversampling at a quarter of a texel. */
16
+ export const DEFAULT_RASTER_STEP = 1.25;
17
+ /** Smallest and largest raster rungs, in texels along the font size. Below `MIN_RASTER_PX` a stem is
18
+ * thinner than a texel; above `MAX_RASTER_PX` a glyph costs more atlas area than it can pay for. */
19
+ export const MIN_RASTER_PX = 6;
20
+ export const MAX_RASTER_PX = 256;
21
+ /**
22
+ * The smallest raster size on the ladder that covers `screenPx`.
23
+ *
24
+ * Rounding up rather than to the nearest rung is what keeps the atlas an upper bound on what the
25
+ * screen asks for: a glyph is never magnified when it is decoded, only minified through the mip
26
+ * chain, which is the direction that still looks sharp.
27
+ */
28
+ export function quantizeRasterPx(screenPx, step = DEFAULT_RASTER_STEP, min = MIN_RASTER_PX, max = MAX_RASTER_PX) {
29
+ if (Number.isNaN(screenPx) || !(screenPx > min))
30
+ return min;
31
+ if (screenPx >= max)
32
+ return max;
33
+ if (!(step > 1))
34
+ return Math.min(max, Math.ceil(screenPx));
35
+ const exponent = Math.ceil(Math.log(screenPx / min) / Math.log(step));
36
+ return Math.min(max, Math.max(min, Math.ceil(min * Math.pow(step, exponent))));
37
+ }
38
+ /** The raster scale a glyph of `fontSize` layout pixels needs in order to land on `rasterPx` texels. */
39
+ export function rasterScaleFor(rasterPx, fontSize) {
40
+ return fontSize > 0 ? rasterPx / fontSize : 1;
41
+ }
42
+ /**
43
+ * Screen pixels one world unit covers at `point`, for the perspective and orthographic matrices the
44
+ * camera writes.
45
+ *
46
+ * This is the same estimate the world-width shaders use (`viewProjection[1][1] * viewportSize.y *
47
+ * 0.5 / clip.w`), so a raster size picked here agrees with the width the shaders compute for the
48
+ * block. It ignores how the block's own plane is angled to the camera, which only matters for the
49
+ * rung chosen; `quantizeRasterPx` absorbs a miss of one rung by oversampling.
50
+ */
51
+ export function screenPixelsPerWorldUnit(viewProjection, point, viewportHeight) {
52
+ const [x, y, z] = point;
53
+ const clipW = viewProjection[3] * x + viewProjection[7] * y + viewProjection[11] * z + viewProjection[15];
54
+ // Behind or on the near plane the projection has no finite scale; fall back to one pixel per unit
55
+ // rather than returning a value that would send the raster ladder to its top rung.
56
+ if (!(clipW > 1e-4))
57
+ return 1;
58
+ const scale = Math.abs(viewProjection[5]) * viewportHeight * 0.5 / clipW;
59
+ return Number.isFinite(scale) && scale > 0 ? scale : 1;
60
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Signed distance field generation for the glyph atlas.
3
+ *
4
+ * Canvas2D hands back an anti-aliased coverage mask, which is enough for the bitmap path but
5
+ * cannot be rescaled: a texel says "80% ink", not "the edge is 0.3 px that way". A distance field
6
+ * stores the latter, so the fragment stage can rebuild the edge at whatever magnification the
7
+ * camera asks for instead of interpolating coverage between texels.
8
+ *
9
+ * The mask is binarised, an exact Euclidean distance transform (Felzenszwalb & Huttenlocher, 2012)
10
+ * gives the distance to the opposite side away from the edge, and partially covered texels override
11
+ * that with the sub-texel offset their own coverage implies — the coverage carries the detail a
12
+ * binary transform would throw away.
13
+ *
14
+ * Values are stored as `distance / (2 × range) + 0.5`, so 128 is the edge, and the field saturates
15
+ * `range` texels either side. `range` is small on purpose: only the neighbourhood of the edge is
16
+ * ever resampled, and a smaller range spends the 8 available bits on a narrower band.
17
+ */
18
+ /** Distance in atlas texels that one side of the encoded range covers. */
19
+ export declare const DEFAULT_SDF_RANGE = 4;
20
+ /**
21
+ * Turns one glyph's coverage mask into an 8-bit distance field, one byte per texel.
22
+ *
23
+ * Positive means inside the glyph. Wholly covered and wholly empty texels take their distance from
24
+ * the transform; partially covered ones use `coverage - 0.5` instead, which is the sub-texel edge
25
+ * offset for a straight edge crossing that texel and is what keeps the field smooth across the
26
+ * boundary rather than quantised to whole texels.
27
+ */
28
+ export declare function coverageToSdf(coverage: Uint8Array, width: number, height: number, range?: number): Uint8Array;