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.
- package/LICENSE +21 -0
- package/README.md +605 -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-draw-list-2d.d.ts +177 -0
- package/dist/frame-draw-list-2d.js +378 -0
- package/dist/frame-draw-list.d.ts +413 -0
- package/dist/frame-draw-list.js +556 -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 +892 -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 +514 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +20 -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/mesh-batch-3d.d.ts +10 -0
- package/dist/mesh-batch-3d.js +67 -0
- package/dist/shape2d-shader.d.ts +14 -0
- package/dist/shape2d-shader.js +288 -0
- package/dist/text-atlas.d.ts +176 -0
- package/dist/text-atlas.js +498 -0
- package/dist/text-font.d.ts +190 -0
- package/dist/text-font.js +261 -0
- package/dist/text-layout.d.ts +175 -0
- package/dist/text-layout.js +390 -0
- package/dist/text-msdf-baked.d.ts +41 -0
- package/dist/text-msdf-baked.js +117 -0
- package/dist/text-msdf-font.d.ts +85 -0
- package/dist/text-msdf-font.js +159 -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-sdf.d.ts +28 -0
- package/dist/text-sdf.js +119 -0
- package/dist/vector-math.d.ts +19 -0
- package/dist/vector-math.js +37 -0
- package/package.json +80 -4
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGSL for the 2D shape pipeline.
|
|
3
|
+
*
|
|
4
|
+
* One instanced quad per shape; the fragment stage evaluates a signed distance field, so a
|
|
5
|
+
* rounded rectangle, an ellipse, an annulus or a regular polygon are all the same pipeline and
|
|
6
|
+
* none of them is expanded to triangles on the CPU. Coverage comes from the screen-space
|
|
7
|
+
* derivative of the distance, which keeps edges one pixel wide at any magnification.
|
|
8
|
+
*
|
|
9
|
+
* The distance is evaluated in the shape's own space (the space its coordinates were given in),
|
|
10
|
+
* then converted to canvas pixels, so a stroke width is always a pixel count. `pixelsPerUnit`
|
|
11
|
+
* and `yFlip` are what make that conversion exact for both 2D spaces: `world2d` has +y up and
|
|
12
|
+
* `zoom` pixels per unit, `screen` has +y down and one.
|
|
13
|
+
*/
|
|
14
|
+
export function shape2dShader() {
|
|
15
|
+
return `
|
|
16
|
+
const PI: f32 = 3.14159265359;
|
|
17
|
+
const TAU: f32 = 6.28318530718;
|
|
18
|
+
const EPSILON: f32 = 1e-6;
|
|
19
|
+
|
|
20
|
+
struct Camera2D {
|
|
21
|
+
viewProjection: mat4x4f,
|
|
22
|
+
viewportSize: vec2f,
|
|
23
|
+
/** +1 when the space has +y down (screen), -1 when it has +y up (world2d). */
|
|
24
|
+
yFlip: f32,
|
|
25
|
+
/** Canvas pixels one shape-space unit covers. */
|
|
26
|
+
pixelsPerUnit: f32,
|
|
27
|
+
}
|
|
28
|
+
@group(0) @binding(0) var<uniform> camera: Camera2D;
|
|
29
|
+
|
|
30
|
+
const SHAPE_RECT: u32 = 0u;
|
|
31
|
+
const SHAPE_ROUNDED_RECT: u32 = 1u;
|
|
32
|
+
const SHAPE_CIRCLE: u32 = 2u;
|
|
33
|
+
const SHAPE_ELLIPSE: u32 = 3u;
|
|
34
|
+
const SHAPE_ARC: u32 = 4u;
|
|
35
|
+
const SHAPE_ANNULUS: u32 = 5u;
|
|
36
|
+
const SHAPE_CAPSULE: u32 = 6u;
|
|
37
|
+
const SHAPE_POLYGON: u32 = 7u;
|
|
38
|
+
const SHAPE_TRIANGLE: u32 = 8u;
|
|
39
|
+
const SHAPE_RHOMBUS: u32 = 9u;
|
|
40
|
+
const SHAPE_SECTOR: u32 = 10u;
|
|
41
|
+
const SHAPE_SEGMENT: u32 = 11u;
|
|
42
|
+
|
|
43
|
+
struct In {
|
|
44
|
+
// The base quad vertex and the per-instance transform the shared instancing layer always
|
|
45
|
+
// provides; both are unused here because the quad is placed from vertex_index.
|
|
46
|
+
@location(0) position: vec3f,
|
|
47
|
+
@location(1) world0: vec4f, @location(2) world1: vec4f,
|
|
48
|
+
@location(3) world2: vec4f, @location(4) world3: vec4f,
|
|
49
|
+
@location(5) shape: u32,
|
|
50
|
+
@location(6) center: vec3f,
|
|
51
|
+
/** Primary half-size: box half extents, ellipse radii, circumradius, capsule radius/half length, annulus outer/inner, or a triangle's first vertex offset. */
|
|
52
|
+
@location(7) size: vec2f,
|
|
53
|
+
@location(8) vertexB: vec2f,
|
|
54
|
+
@location(9) vertexC: vec2f,
|
|
55
|
+
/** (cornerRadius, strokeWidthPixels, startAngle, sweep). */
|
|
56
|
+
@location(10) params: vec4f,
|
|
57
|
+
/** (rotation, bounding radius of the instanced quad). */
|
|
58
|
+
@location(11) rotationRadius: vec2f,
|
|
59
|
+
@location(12) color: vec4f,
|
|
60
|
+
/** bit0: filled; bits 8..15: polygon vertex count. */
|
|
61
|
+
@location(13) flags: u32,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
struct Out {
|
|
65
|
+
@builtin(position) position: vec4f,
|
|
66
|
+
@location(0) color: vec4f,
|
|
67
|
+
// Integer varyings have to be flat, and this device runs in WebGPU compatibility mode where
|
|
68
|
+
// flat's default "first" sampling is rejected: either is the portable form.
|
|
69
|
+
@location(1) @interpolate(flat, either) shape: u32,
|
|
70
|
+
@location(2) centerPixel: vec2f,
|
|
71
|
+
@location(3) size: vec2f,
|
|
72
|
+
@location(4) vertexB: vec2f,
|
|
73
|
+
@location(5) vertexC: vec2f,
|
|
74
|
+
@location(6) params: vec4f,
|
|
75
|
+
@location(7) rotation: f32,
|
|
76
|
+
@location(8) @interpolate(flat, either) sides: u32,
|
|
77
|
+
@location(9) @interpolate(flat, either) flags: u32,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fn rotate2(p: vec2f, angle: f32) -> vec2f {
|
|
81
|
+
let c = cos(angle);
|
|
82
|
+
let s = sin(angle);
|
|
83
|
+
return vec2f(c * p.x - s * p.y, s * p.x + c * p.y);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Floored modulo: WGSL's % truncates, which would fold negative angles the wrong way. */
|
|
87
|
+
fn floored_mod(value: f32, period: f32) -> f32 {
|
|
88
|
+
return value - period * floor(value / period);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
fn sd_box(p: vec2f, halfSize: vec2f) -> f32 {
|
|
92
|
+
let d = abs(p) - halfSize;
|
|
93
|
+
return length(max(d, vec2f(0.0))) + min(max(d.x, d.y), 0.0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
fn sd_rounded_box(p: vec2f, halfSize: vec2f, radius: f32) -> f32 {
|
|
97
|
+
let r = min(max(radius, 0.0), min(halfSize.x, halfSize.y));
|
|
98
|
+
return sd_box(p, halfSize - vec2f(r)) - r;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Ellipse distance (iq's approximation). It is exact when the two axes are equal, which is why
|
|
103
|
+
* circle shares it, and within a fraction of a pixel elsewhere.
|
|
104
|
+
*
|
|
105
|
+
* Both k terms divide by a different power of the radii: k1 by the radii and k2 by their square.
|
|
106
|
+
* Dividing both by the square collapses the result to a constant and paints the whole quad.
|
|
107
|
+
*/
|
|
108
|
+
fn sd_ellipse(p: vec2f, radii: vec2f) -> f32 {
|
|
109
|
+
let ab = max(radii, vec2f(EPSILON));
|
|
110
|
+
let ab2 = ab * ab;
|
|
111
|
+
let k1 = length(p / ab);
|
|
112
|
+
let k2 = length(p / ab2);
|
|
113
|
+
if (k2 < EPSILON) { return -min(ab.x, ab.y); }
|
|
114
|
+
return k1 * (k1 - 1.0) / k2;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fn sd_segment(p: vec2f, a: vec2f, b: vec2f) -> f32 {
|
|
118
|
+
let pa = p - a;
|
|
119
|
+
let ba = b - a;
|
|
120
|
+
let h = clamp(dot(pa, ba) / max(dot(ba, ba), EPSILON), 0.0, 1.0);
|
|
121
|
+
return length(pa - ba * h);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Ring band: negative between inner and outer. */
|
|
125
|
+
fn sd_annulus(p: vec2f, inner: f32, outer: f32) -> f32 {
|
|
126
|
+
let mid = (inner + outer) * 0.5;
|
|
127
|
+
return abs(length(p) - mid) - (outer - inner) * 0.5;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
fn sd_capsule(p: vec2f, halfLength: f32, radius: f32) -> f32 {
|
|
131
|
+
return sd_segment(p, vec2f(0.0, -halfLength), vec2f(0.0, halfLength)) - radius;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Regular polygon with a vertex on the +x axis: distance to the edge of the nearest sector. */
|
|
135
|
+
fn sd_regular_polygon(p: vec2f, radius: f32, sides: u32) -> f32 {
|
|
136
|
+
let n = max(f32(sides), 3.0);
|
|
137
|
+
let apothem = PI / n;
|
|
138
|
+
let angle = atan2(p.y, p.x);
|
|
139
|
+
// Fold the angle into the sector around the nearest edge midpoint, which sits apothem away
|
|
140
|
+
// from a vertex. Zero means the point is dead on an edge normal.
|
|
141
|
+
let alpha = abs(floored_mod(angle, 2.0 * apothem) - apothem);
|
|
142
|
+
// Every edge is a line perpendicular to its midpoint radius at radius * cos(apothem).
|
|
143
|
+
return length(p) * cos(alpha) - radius * cos(apothem);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Arbitrary triangle, signed distance (iq). */
|
|
147
|
+
fn sd_triangle(p: vec2f, p0: vec2f, p1: vec2f, p2: vec2f) -> f32 {
|
|
148
|
+
let e0 = p1 - p0;
|
|
149
|
+
let v0 = p - p0;
|
|
150
|
+
let e1 = p2 - p1;
|
|
151
|
+
let v1 = p - p1;
|
|
152
|
+
let e2 = p0 - p2;
|
|
153
|
+
let v2 = p - p2;
|
|
154
|
+
let pq0 = v0 - e0 * clamp(dot(v0, e0) / max(dot(e0, e0), EPSILON), 0.0, 1.0);
|
|
155
|
+
let pq1 = v1 - e1 * clamp(dot(v1, e1) / max(dot(e1, e1), EPSILON), 0.0, 1.0);
|
|
156
|
+
let pq2 = v2 - e2 * clamp(dot(v2, e2) / max(dot(e2, e2), EPSILON), 0.0, 1.0);
|
|
157
|
+
let s = sign(e0.x * e2.y - e0.y * e2.x);
|
|
158
|
+
let d0 = vec2f(dot(pq0, pq0), s * (v0.x * e0.y - v0.y * e0.x));
|
|
159
|
+
let d1 = vec2f(dot(pq1, pq1), s * (v1.x * e1.y - v1.y * e1.x));
|
|
160
|
+
let d2 = vec2f(dot(pq2, pq2), s * (v2.x * e2.y - v2.y * e2.x));
|
|
161
|
+
let d = min(min(d0, d1), d2);
|
|
162
|
+
return -sqrt(max(d.x, 0.0)) * sign(d.y);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Diamond with half diagonals a and b; the gradient is constant, so this is exact. */
|
|
166
|
+
fn sd_rhombus(p: vec2f, a: f32, b: f32) -> f32 {
|
|
167
|
+
let dx = max(a, EPSILON);
|
|
168
|
+
let dy = max(b, EPSILON);
|
|
169
|
+
let value = abs(p.x) / dx + abs(p.y) / dy - 1.0;
|
|
170
|
+
return value / length(vec2f(1.0 / dx, 1.0 / dy));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Sector of at most half a turn: the intersection of the disc and the two edge half-planes. */
|
|
174
|
+
fn sd_convex_sector(p: vec2f, radius: f32, start: f32, sweep: f32) -> f32 {
|
|
175
|
+
let end = start + sweep;
|
|
176
|
+
// Inward normals of the two straight edges, in the order the sweep turns.
|
|
177
|
+
let startEdge = vec2f(sin(start), -cos(start));
|
|
178
|
+
let endEdge = vec2f(-sin(end), cos(end));
|
|
179
|
+
return max(length(p) - radius, max(dot(p, startEdge), dot(p, endEdge)));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Sector of any sweep. Past half a turn the two half-planes would bound the complementary wedge
|
|
184
|
+
* instead, so the complement is subtracted from the disc. A negative sweep runs the other way.
|
|
185
|
+
*/
|
|
186
|
+
fn sd_sector(p: vec2f, radius: f32, start: f32, sweep: f32) -> f32 {
|
|
187
|
+
let disc = length(p) - radius;
|
|
188
|
+
if (abs(sweep) >= TAU - EPSILON) { return disc; }
|
|
189
|
+
if (abs(sweep) <= PI) { return max(disc, sd_convex_sector(p, radius, start, sweep)); }
|
|
190
|
+
let complementSweep = select(TAU + sweep, TAU - sweep, sweep > 0.0);
|
|
191
|
+
let complement = sd_convex_sector(p, radius, start + sweep, complementSweep);
|
|
192
|
+
return max(disc, -complement);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Circular segment: the disc on the arc side of the chord. Always convex. */
|
|
196
|
+
fn sd_segment_shape(p: vec2f, radius: f32, start: f32, sweep: f32) -> f32 {
|
|
197
|
+
if (abs(sweep) >= TAU - EPSILON) { return length(p) - radius; }
|
|
198
|
+
let a = vec2f(cos(start), sin(start)) * radius;
|
|
199
|
+
let b = vec2f(cos(start + sweep), sin(start + sweep)) * radius;
|
|
200
|
+
let middle = vec2f(cos(start + sweep * 0.5), sin(start + sweep * 0.5)) * radius;
|
|
201
|
+
var normal = vec2f(-(b.y - a.y), b.x - a.x);
|
|
202
|
+
if (dot(normal, middle - a) < 0.0) { normal = -normal; }
|
|
203
|
+
let unit = normal * inverseSqrt(max(dot(normal, normal), EPSILON));
|
|
204
|
+
return max(length(p) - radius, -dot(p - a, unit));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
fn shape_distance(shape: u32, p: vec2f, size: vec2f, vertexB: vec2f, vertexC: vec2f, params: vec4f, sides: u32) -> f32 {
|
|
208
|
+
let cornerRadius = params.x;
|
|
209
|
+
let startAngle = params.z;
|
|
210
|
+
let sweep = params.w;
|
|
211
|
+
switch shape {
|
|
212
|
+
case SHAPE_RECT: { return sd_rounded_box(p, size, 0.0); }
|
|
213
|
+
case SHAPE_ROUNDED_RECT: { return sd_rounded_box(p, size, cornerRadius); }
|
|
214
|
+
case SHAPE_CIRCLE: { return sd_ellipse(p, size); }
|
|
215
|
+
case SHAPE_ELLIPSE: { return sd_ellipse(p, size); }
|
|
216
|
+
// An arc has no interior: the ring alone ignores the angles, so it is clipped to the swept
|
|
217
|
+
// wedge. Filling one still yields a hairline, because the distance stays unsigned.
|
|
218
|
+
case SHAPE_ARC: { return max(abs(length(p) - size.x), sd_sector(p, size.x, startAngle, sweep)); }
|
|
219
|
+
case SHAPE_ANNULUS: { return sd_annulus(p, size.y, size.x); }
|
|
220
|
+
case SHAPE_CAPSULE: { return sd_capsule(p, size.y, size.x); }
|
|
221
|
+
case SHAPE_POLYGON: { return sd_regular_polygon(p, size.x, sides); }
|
|
222
|
+
case SHAPE_TRIANGLE: { return sd_triangle(p, size, vertexB, vertexC); }
|
|
223
|
+
case SHAPE_RHOMBUS: { return sd_rhombus(p, size.x, size.y); }
|
|
224
|
+
case SHAPE_SECTOR: { return sd_sector(p, size.x, startAngle, sweep); }
|
|
225
|
+
case SHAPE_SEGMENT: { return sd_segment_shape(p, size.x, startAngle, sweep); }
|
|
226
|
+
default: { return 1e6; }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
fn clip_to_framebuffer(clip: vec4f) -> vec2f {
|
|
231
|
+
let ndc = clip.xy / max(clip.w, EPSILON);
|
|
232
|
+
return vec2f((ndc.x * 0.5 + 0.5) * camera.viewportSize.x, (0.5 - ndc.y * 0.5) * camera.viewportSize.y);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
@vertex fn vs_main(input: In, @builtin(vertex_index) vertex: u32) -> Out {
|
|
236
|
+
let corners = array<vec2f, 6>(
|
|
237
|
+
vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(-1.0, 1.0),
|
|
238
|
+
vec2f(-1.0, 1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0),
|
|
239
|
+
);
|
|
240
|
+
let clip = camera.viewProjection * vec4f(input.center, 1.0);
|
|
241
|
+
let scale = max(camera.pixelsPerUnit, EPSILON);
|
|
242
|
+
let rotation = input.rotationRadius.x;
|
|
243
|
+
let radius = input.rotationRadius.y;
|
|
244
|
+
// A stroke extends past the shape, so the quad has to cover its outer edge too.
|
|
245
|
+
let stroke = select(input.params.y, 0.0, (input.flags & 1u) != 0u);
|
|
246
|
+
let extent = radius + stroke * 0.5 / scale;
|
|
247
|
+
let offset = corners[vertex] * extent * scale;
|
|
248
|
+
|
|
249
|
+
var out: Out;
|
|
250
|
+
out.position = vec4f(
|
|
251
|
+
clip.x + offset.x / camera.viewportSize.x * 2.0 * clip.w,
|
|
252
|
+
clip.y - offset.y / camera.viewportSize.y * 2.0 * clip.w,
|
|
253
|
+
// The 2D layer pins itself to the near plane and never writes depth, like screen-space text.
|
|
254
|
+
0.0,
|
|
255
|
+
clip.w,
|
|
256
|
+
);
|
|
257
|
+
out.color = input.color;
|
|
258
|
+
out.shape = input.shape;
|
|
259
|
+
out.centerPixel = clip_to_framebuffer(clip);
|
|
260
|
+
out.size = input.size;
|
|
261
|
+
out.vertexB = input.vertexB;
|
|
262
|
+
out.vertexC = input.vertexC;
|
|
263
|
+
out.params = input.params;
|
|
264
|
+
out.rotation = rotation;
|
|
265
|
+
out.sides = (input.flags >> 8u) & 0xffu;
|
|
266
|
+
out.flags = input.flags;
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
@fragment fn fs_main(input: Out) -> @location(0) vec4f {
|
|
271
|
+
let pixelOffset = input.position.xy - input.centerPixel;
|
|
272
|
+
let scale = max(camera.pixelsPerUnit, EPSILON);
|
|
273
|
+
// Undo the space's y direction so the shape sees the coordinates it was authored in.
|
|
274
|
+
let offset = vec2f(pixelOffset.x, pixelOffset.y * camera.yFlip) / scale;
|
|
275
|
+
let local = rotate2(offset, -input.rotation);
|
|
276
|
+
|
|
277
|
+
let distanceLocal = shape_distance(input.shape, local, input.size, input.vertexB, input.vertexC, input.params, input.sides);
|
|
278
|
+
let distancePixel = distanceLocal * scale;
|
|
279
|
+
let filled = (input.flags & 1u) != 0u;
|
|
280
|
+
// Relative to the outer edge of the stroke, so one coverage ramp serves fill and stroke alike.
|
|
281
|
+
let signedPixel = select(abs(distancePixel) - max(input.params.y, 0.0) * 0.5, distancePixel, filled);
|
|
282
|
+
let width = max(fwidth(signedPixel), EPSILON);
|
|
283
|
+
let coverage = clamp(0.5 - signedPixel / width, 0.0, 1.0);
|
|
284
|
+
if (coverage <= 0.0) { discard; }
|
|
285
|
+
return vec4f(input.color.rgb, input.color.a * coverage);
|
|
286
|
+
}
|
|
287
|
+
`;
|
|
288
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
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 { type FontFace } from "./text-layout.js";
|
|
22
|
+
import type { GlyphEncoding, GlyphRasterizer } from "./text-font.js";
|
|
23
|
+
/** Page size and inter-glyph padding, matching reference minimum atlas and `GLYPH_ATLAS_PADDING`. */
|
|
24
|
+
export declare const DEFAULT_ATLAS_SIZE = 512;
|
|
25
|
+
export declare const DEFAULT_ATLAS_PADDING = 2;
|
|
26
|
+
/** Re-exported so a caller can name an encoding without reaching into the font module. */
|
|
27
|
+
export type { GlyphEncoding };
|
|
28
|
+
/**
|
|
29
|
+
* Which channels hold a distance, per encoding. A mip chain has to resample those through coverage
|
|
30
|
+
* rather than averaging the values; everything else in a texel is signal that averages.
|
|
31
|
+
*/
|
|
32
|
+
export declare const DISTANCE_CHANNELS: Record<GlyphEncoding, readonly number[]>;
|
|
33
|
+
/** Where one glyph lives in the atlas, plus the metrics needed to place its quad. */
|
|
34
|
+
export type AtlasGlyph = {
|
|
35
|
+
readonly page: number;
|
|
36
|
+
/** Normalized ink rectangle `[u0, v0, u1, v1]`, v measured downwards from the page's first row. */
|
|
37
|
+
readonly uv: readonly [number, number, number, number];
|
|
38
|
+
/** Ink box left edge relative to the pen, y down. */
|
|
39
|
+
readonly bearingLeft: number;
|
|
40
|
+
/** Ink box top edge relative to the baseline, y down. */
|
|
41
|
+
readonly bearingTop: number;
|
|
42
|
+
/** Ink box size in the units the glyph was measured in. */
|
|
43
|
+
readonly width: number;
|
|
44
|
+
readonly height: number;
|
|
45
|
+
readonly advance: number;
|
|
46
|
+
/**
|
|
47
|
+
* Texels per layout font pixel this entry was rasterised at, `rasterPx / fontSize`. One atlas holds
|
|
48
|
+
* entries from several rungs of the raster ladder, so the shader reads the ratio per glyph rather
|
|
49
|
+
* than from a constant; it is what converts a font-pixel outline width into sampled texels.
|
|
50
|
+
*/
|
|
51
|
+
readonly rasterScale: number;
|
|
52
|
+
/** Frame this glyph was last requested in. Glyphs older than the current frame are recyclable. */
|
|
53
|
+
lastFrame: number;
|
|
54
|
+
/** Packed rectangle in page texels, padding included, so eviction can hand the space back. */
|
|
55
|
+
readonly atlasX: number;
|
|
56
|
+
readonly atlasY: number;
|
|
57
|
+
readonly atlasWidth: number;
|
|
58
|
+
readonly atlasHeight: number;
|
|
59
|
+
};
|
|
60
|
+
/** Texel rectangle of a page that changed since the renderer last copied it to the GPU. */
|
|
61
|
+
export type AtlasDirtyRect = {
|
|
62
|
+
readonly x: number;
|
|
63
|
+
readonly y: number;
|
|
64
|
+
readonly width: number;
|
|
65
|
+
readonly height: number;
|
|
66
|
+
};
|
|
67
|
+
/** One RGBA8 page. `revision` is bumped on every write so the renderer can upload lazily. */
|
|
68
|
+
export type AtlasPage = {
|
|
69
|
+
readonly index: number;
|
|
70
|
+
readonly size: number;
|
|
71
|
+
/** Tightly packed RGBA8 rows, `size * size * 4` bytes. */
|
|
72
|
+
readonly data: Uint8Array;
|
|
73
|
+
revision: number;
|
|
74
|
+
/**
|
|
75
|
+
* Union of every rectangle written since the last upload, or `undefined` when the page is clean
|
|
76
|
+
* on the GPU. One glyph costs a few hundred bytes to upload instead of a whole page.
|
|
77
|
+
*/
|
|
78
|
+
dirty: AtlasDirtyRect | undefined;
|
|
79
|
+
};
|
|
80
|
+
export declare class GlyphAtlas {
|
|
81
|
+
#private;
|
|
82
|
+
constructor(rasterizer: GlyphRasterizer, options?: {
|
|
83
|
+
size?: number;
|
|
84
|
+
padding?: number;
|
|
85
|
+
maxPages?: number;
|
|
86
|
+
onFull?: "throw" | "skip";
|
|
87
|
+
});
|
|
88
|
+
/** The encoding the glyph source writes, which is also how the shader will decode the page. */
|
|
89
|
+
get encoding(): GlyphEncoding;
|
|
90
|
+
/** The distance range the source encodes with, if it uses a distance field at all. */
|
|
91
|
+
get distanceRange(): number | undefined;
|
|
92
|
+
/** How much larger the source's texels are than layout units. See `GlyphRasterizer.rasterScale`. */
|
|
93
|
+
get rasterScale(): number;
|
|
94
|
+
get pages(): readonly AtlasPage[];
|
|
95
|
+
/**
|
|
96
|
+
* Glyphs dropped because the atlas was full and no page could be recycled, which only happens when
|
|
97
|
+
* the caller chose `onFull: "skip"`. A renderer reports it so a frame that is quietly missing a
|
|
98
|
+
* character can be told apart from one the font never had.
|
|
99
|
+
*/
|
|
100
|
+
get skipped(): number;
|
|
101
|
+
/**
|
|
102
|
+
* Marks the start of a frame. Glyphs that were not requested in the current frame become
|
|
103
|
+
* recyclable, so a bounded atlas stops being a hard failure once on-screen text changes shape.
|
|
104
|
+
* Call it once per frame, before the frame's glyphs are requested.
|
|
105
|
+
*/
|
|
106
|
+
beginFrame(): void;
|
|
107
|
+
/**
|
|
108
|
+
* Returns the atlas entry for a character, rasterising and packing it on first use.
|
|
109
|
+
* `undefined` means the character has no ink (whitespace) or the rasteriser has nothing for
|
|
110
|
+
* it; the layout layer has already dropped those glyphs, so callers treat it as a skip.
|
|
111
|
+
*
|
|
112
|
+
* `rasterScale` defaults to the source's own scale, which keeps a fixed-raster source drawing
|
|
113
|
+
* exactly as it did. The renderer passes the size the block covers on screen instead, so the entry
|
|
114
|
+
* that comes back is rasterised for the rung that face asked for.
|
|
115
|
+
*/
|
|
116
|
+
glyph(character: string, face: FontFace, fontSize: number, rasterScale?: number): AtlasGlyph | undefined;
|
|
117
|
+
/** Drops every cached glyph. The next frame re-rasterises whatever it still draws. */
|
|
118
|
+
clear(): void;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Maps a level-0 dirty rectangle onto one mip level. Every level halves the coordinates and grows
|
|
122
|
+
* the rectangle by one texel, because a box filter reads across the edge it averages.
|
|
123
|
+
*/
|
|
124
|
+
export declare function dirtyAtLevel(dirty: AtlasDirtyRect, level: number, pageSize: number): AtlasDirtyRect;
|
|
125
|
+
/**
|
|
126
|
+
* Box-downsamples one mip level: every output texel averages its 2×2 source block, clamped at the
|
|
127
|
+
* edge. The page is a tintable mask — RGB is a constant 255 and all the signal sits in the coverage
|
|
128
|
+
* channel — so averaging is exactly the right minification filter here. A distance field would need
|
|
129
|
+
* the usual distance-compression trick instead, which is why an encoder that stores one keeps its
|
|
130
|
+
* own resampling.
|
|
131
|
+
*/
|
|
132
|
+
export declare function downsamplePage(data: Uint8Array, size: number): Uint8Array;
|
|
133
|
+
/**
|
|
134
|
+
* Box-downsamples one mip level of a distance field.
|
|
135
|
+
*
|
|
136
|
+
* Averaging stored distances is not the same filter. A distance is the signed distance to the nearest
|
|
137
|
+
* edge, so where the field saturates its magnitude carries no information — a block that is half
|
|
138
|
+
* deeply-inside ink and half just-outside ink averages to a value that says "far inside" while its
|
|
139
|
+
* real coverage is a half — and a level that feeds that back through `clamp(distance + 0.5)` loses or
|
|
140
|
+
* invents ink. Measured against the geometric coverage of the same glyphs, a level built this way
|
|
141
|
+
* reads about 10% light. Decoding each value as the coverage it implies, averaging that, and
|
|
142
|
+
* encoding the result back is the filter that behaves like the coverage mask's own.
|
|
143
|
+
*
|
|
144
|
+
* The stored value keeps meaning "distance in level-0 texels, range `range`", which is what the
|
|
145
|
+
* shader's decode assumes once it scales its range by the level it sampled.
|
|
146
|
+
*
|
|
147
|
+
* `distanceChannels` says which channels this applies to, since a texel may mix the two.
|
|
148
|
+
*/
|
|
149
|
+
export declare function downsampleDistancePage(data: Uint8Array, size: number, range: number, distanceChannels: readonly number[]): Uint8Array;
|
|
150
|
+
/** One level of a page's mip chain, level 0 being the page itself. */
|
|
151
|
+
export type MipLevel = {
|
|
152
|
+
readonly size: number;
|
|
153
|
+
readonly data: Uint8Array;
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* Builds a square page's mip chain, stopping early at 1×1 or once `levels` are reached.
|
|
157
|
+
*
|
|
158
|
+
* `downsample` defaults to the coverage box filter. A distance-field page passes
|
|
159
|
+
* `downsampleDistancePage` instead, so its levels keep reporting coverage rather than an average
|
|
160
|
+
* distance.
|
|
161
|
+
*/
|
|
162
|
+
export declare function buildMipChain(data: Uint8Array, size: number, levels: number, downsample?: (data: Uint8Array, size: number) => Uint8Array): readonly MipLevel[];
|
|
163
|
+
/**
|
|
164
|
+
* Extracts one rectangle of a page and pads its rows to WebGPU's 256 byte `bytesPerRow`
|
|
165
|
+
* requirement, which only binds once a rectangle has more than one row. The copy is a few hundred
|
|
166
|
+
* bytes for a single glyph, so it is not worth avoiding.
|
|
167
|
+
*/
|
|
168
|
+
export declare function extractRegion(data: Uint8Array, size: number, rect: AtlasDirtyRect): {
|
|
169
|
+
readonly data: Uint8Array;
|
|
170
|
+
readonly bytesPerRow: number;
|
|
171
|
+
};
|
|
172
|
+
/** Pads one whole mip level's rows through `extractRegion`. */
|
|
173
|
+
export declare function padMipRows(data: Uint8Array, size: number): {
|
|
174
|
+
readonly data: Uint8Array;
|
|
175
|
+
readonly bytesPerRow: number;
|
|
176
|
+
};
|