@godot-scene-web/hb-gpu 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/dist/webgl.js ADDED
@@ -0,0 +1,1624 @@
1
+ import "./index.js";
2
+ //#region src/webgl.ts
3
+ /**
4
+ * Upstream's `ATLAS_TEX_WIDTH`, and the width this renderer PREFERS rather than the one it gets.
5
+ *
6
+ * Wide and short keeps the unwrap arithmetic in `int` range. WebGL2 only guarantees
7
+ * `MAX_TEXTURE_SIZE >= 2048`, so a device can refuse 4096 — read {@link HbGpuRenderer.atlasWidth}
8
+ * for the width an instance actually built, and never assume this constant describes it. The
9
+ * shader is handed the achieved width as `hb_gpu_atlas_width`, so any width is legal.
10
+ */
11
+ const ATLAS_WIDTH = 4096;
12
+ /**
13
+ * Below this the renderer refuses rather than clamps.
14
+ *
15
+ * A 5 KB Han blob is ~675 texels: at 1024 wide the upload is at most a couple of `texSubImage2D`
16
+ * calls, and narrower rows turn one glyph into dozens of them. A device reporting below WebGL2's
17
+ * own guaranteed 2048 is broken or emulated, and is not one this renderer can be measured on.
18
+ */
19
+ const MIN_ATLAS_WIDTH = 1024;
20
+ const LEGACY_INSTANCE_FLOATS = 10;
21
+ const BATCHED_INSTANCE_FLOATS = 21;
22
+ /**
23
+ * The GLSL preamble every stage gets, and the one line in it that is load-bearing.
24
+ *
25
+ * `#define HB_GPU_ATLAS_2D` selects `uniform highp isampler2D hb_gpu_atlas` plus the
26
+ * `hb_gpu_atlas_width` unwrap over the `isamplerBuffer` path. It is defined HERE, in the shader,
27
+ * and not by the wasm build: the C macro of the same name only reaches `util/gpu/demo-atlas.cc`,
28
+ * which this package does not compile, so the `-DHB_GPU_ATLAS_2D` on the em++ line does not select
29
+ * anything. The `#ifdef` that matters lives inside the GLSL the module hands back.
30
+ *
31
+ * `highp int` is not decoration either: atlas offsets are absolute texel indices into a stream that
32
+ * runs to hundreds of thousands, and `mediump int` is only guaranteed 16 bits. Worse, a precision
33
+ * that disagrees across the two stages fails to LINK with no diagnostic on some drivers, so this
34
+ * string is shared by both on purpose.
35
+ */
36
+ const GLSL_PREAMBLE = "#version 300 es\nprecision highp float;\nprecision highp int;\n#define HB_GPU_ATLAS_2D\n";
37
+ /**
38
+ * OUR macro, and the reason it is not HarfBuzz's `HB_GPU_NO_MSAA`, which is the whole safety
39
+ * argument for {@link HbGpuRendererOptions.spreadTapMsaa}.
40
+ *
41
+ * `HB_GPU_NO_MSAA` is defined by the vendored library and guards `_hb_gpu_slug` — the FILL. Reusing
42
+ * it to switch the dilation's taps would switch the fill's five-sample average off at the same time,
43
+ * silently, and the fill is the one thing this trade is not allowed to touch. A separate name makes
44
+ * that structural rather than a thing to remember: `hb_gpu_draw` reaches `_hb_gpu_slug` inside the
45
+ * library's own source, which this define cannot reach into.
46
+ */
47
+ const SPREAD_TAP_NO_MSAA_DEFINE = "#define HB_GPU_SPREAD_TAP_NO_MSAA\n";
48
+ /**
49
+ * THE DILATION'S WHOLE TAP BUDGET, as one number, and the reason it is one number.
50
+ *
51
+ * It bounds the flat loop in {@link FRAGMENT_MAIN} — one tap per iteration — so the compile-time
52
+ * ceiling IS the worst-case tap count: 64 unrolled sites, plus `hb_gpu_draw`'s own centre tap, is 65
53
+ * evaluations for the most expensive fragment there is. That was also the ceiling of the nested
54
+ * `4 rings x 16 steps` loops this replaced, so the redistribution below is free at the top end.
55
+ *
56
+ * WHY THE SHAPE CHANGED RATHER THAN THE NUMBER. The nested form clamped EVERY ring to the same 16
57
+ * steps, so past `radius * t > 2.5 px` all four rings ran 16 taps — and the outermost one, which is
58
+ * the only ring that decides where the dilated boundary lands, was then the SPARSEST: at radius 12
59
+ * its taps sit 4.71 px of arc apart, while ring 1 spends the same 16 on a circle a quarter the size
60
+ * and puts them 1.18 px apart. Giving
61
+ * the outer ring more steps in that shape would have meant raising the per-ring cap, and 4 x 28 is
62
+ * 112 unrolled sites for a budget most fragments never spend. A flat loop makes "how many taps may a
63
+ * fragment cost" and "how are they arranged" two independent decisions, and only the first one is a
64
+ * perf number.
65
+ *
66
+ * INTERPOLATED INTO THE GLSL rather than restated there, so `spreadBudget.test.ts` can assert on the
67
+ * arithmetic that divides it between rings and on the ceiling reaching the shader, from one source.
68
+ */
69
+ const HB_GPU_SPREAD_MAX_TAPS = 64;
70
+ /**
71
+ * OUR entry point for the vertex stage. Roughly 30 lines, and unavoidably ours.
72
+ *
73
+ * HarfBuzz ships `hb_gpu_dilate` and no `main`, because it cannot know what a consumer's
74
+ * attributes are called. Upstream writes the same wrapper for its demo in
75
+ * `util/gpu/demo-vertex.glsl`; this one is written against `hb-gpu-vertex.glsl`'s documented
76
+ * contract rather than copied from it, and it differs where the pipeline does — a corner is
77
+ * interpolated out of an instance record here, where upstream reads four expanded vertices.
78
+ *
79
+ * `jac` IS THE INVERSE OF THE EM-TO-OBJECT LINEAR PART, and the y term is negative because em
80
+ * space is y-up and this renderer's object space is y-down device pixels. HarfBuzz's header spells
81
+ * out the case: em-to-object `[[s, 0], [0, -s]]` gives `jac = (1/s, 0, 0, -1/s)`, and `1/s` is
82
+ * exactly `a_emPerPos`. Drop the sign and the dilation pushes the texcoord the wrong way in y,
83
+ * which shows up as a half-pixel of missing ink along horizontal edges only.
84
+ *
85
+ * IT ALSO GROWS THE QUAD BY {@link HbGpuRenderer.setSpread}, AND WITHOUT THAT THERE IS NO OUTLINE
86
+ * AT ALL. `a_position` / `a_texcoord` are the glyph's INK box: the fragment stage's dilation is a
87
+ * max of coverage taps up to `spread` away, so ink that is `spread` OUTSIDE the box has to have a
88
+ * fragment to be found from. Left out, every tap that would have reached ink is simply never
89
+ * rasterised and the whole feature fails as "the outline did not appear" — silently, with a
90
+ * perfectly correct-looking fill. Measured by deleting these two lines: the dilated ink box then
91
+ * grows 0-1 px instead of 4, while the superset and interior checks stay green.
92
+ *
93
+ * THE EXPANSION GOES THROUGH `jac`, not through a hand-written sign pattern, and that is worth the
94
+ * one extra line. `a_normal` IS the object-space outward normal at this corner (the file header
95
+ * says why), so `a_normal * a_spreadPx` is the object-space displacement; `jac` is by definition
96
+ * the map from an object displacement to the em displacement that matches it, which is exactly
97
+ * what `hb_gpu_dilate` uses it for two lines below. Spelling the em half out separately would be a
98
+ * second chance to get the y flip backwards, and a backwards y flip here reads as an outline that
99
+ * is present but shifted — not as an error.
100
+ */
101
+ const BATCHED_VERTEX_MAIN = `
102
+ uniform mat4 u_viewProjection;
103
+ uniform vec2 u_viewport;
104
+
105
+ /* Per-vertex: the corner sign, which IS the outward normal at that corner. */
106
+ in vec2 a_normal;
107
+
108
+ /* Per-instance, divisor 1. */
109
+ in vec4 a_position; /* object-space box: (x at cx=0, y at cy=0, x at cx=1, y at cy=1) */
110
+ in vec4 a_texcoord; /* em-space ink box: (minX, minY, maxX, maxY) */
111
+ in float a_emPerPos; /* em units per object unit, i.e. upem / pixelsPerEm */
112
+ in uint a_glyphLoc; /* first texel of this glyph's blob in the atlas */
113
+ in vec2 a_model0;
114
+ in vec2 a_model1;
115
+ in vec2 a_model2;
116
+ in vec4 a_color;
117
+ in float a_spreadPx;
118
+
119
+ out vec2 v_texcoord;
120
+ flat out uint v_glyphLoc;
121
+ /* The spread in EM units for THIS instance. Flat, and a varying rather than a second uniform,
122
+ * because the object-to-em factor is a_emPerPos and that is per-instance: one uniform in em units
123
+ * could not mean the same number of pixels for two glyphs pushed at different pixelsPerEm. */
124
+ flat out float v_spreadEm;
125
+ flat out float v_spreadPx;
126
+ flat out vec4 v_color;
127
+
128
+ void main ()
129
+ {
130
+ /* (-1, +1) -> (0, 0) and (+1, -1) -> (1, 1): the y term is flipped because a corner's outward
131
+ * normal points UP in object space exactly when it is the box's minimum em coordinate. */
132
+ vec2 corner = vec2 (a_normal.x, -a_normal.y) * 0.5 + 0.5;
133
+
134
+ vec2 pos = mix (a_position.xy, a_position.zw, corner);
135
+ vec2 tex = mix (a_texcoord.xy, a_texcoord.zw, corner);
136
+
137
+ vec4 jac = vec4 (a_emPerPos, 0.0, 0.0, -a_emPerPos);
138
+ mat4 model = mat4 (
139
+ vec4 (a_model0, 0.0, 0.0),
140
+ vec4 (a_model1, 0.0, 0.0),
141
+ vec4 (0.0, 0.0, 1.0, 0.0),
142
+ vec4 (a_model2, 0.0, 1.0));
143
+ mat4 mvp = u_viewProjection * model;
144
+ float spreadPx = a_spreadPx;
145
+ vec4 color = a_color;
146
+
147
+ /* At spread 0 both of these add 0.0, which is the identity for every finite value — the fill
148
+ * path really is byte-for-byte what it was before the outline path existed. */
149
+ vec2 spreadPos = a_normal * spreadPx;
150
+ pos += spreadPos;
151
+ tex += vec2 (dot (spreadPos, jac.xy), dot (spreadPos, jac.zw));
152
+
153
+ hb_gpu_dilate (pos, tex, a_normal, jac, mvp, u_viewport);
154
+
155
+ gl_Position = mvp * vec4 (pos, 0.0, 1.0);
156
+ v_texcoord = tex;
157
+ v_glyphLoc = a_glyphLoc;
158
+ v_spreadEm = spreadPx * a_emPerPos;
159
+ v_spreadPx = spreadPx;
160
+ v_color = color;
161
+ }
162
+ `;
163
+ const VERTEX_MAIN = `
164
+ uniform mat4 u_matViewProjection;
165
+ uniform vec2 u_viewport;
166
+ uniform float u_spreadPx; /* OBJECT units, pre-model. 0 disables the whole path. */
167
+
168
+ /* Per-vertex: the corner sign, which IS the outward normal at that corner. */
169
+ in vec2 a_normal;
170
+
171
+ /* Per-instance, divisor 1. */
172
+ in vec4 a_position; /* object-space box: (x at cx=0, y at cy=0, x at cx=1, y at cy=1) */
173
+ in vec4 a_texcoord; /* em-space ink box: (minX, minY, maxX, maxY) */
174
+ in float a_emPerPos; /* em units per object unit, i.e. upem / pixelsPerEm */
175
+ in uint a_glyphLoc; /* first texel of this glyph's blob in the atlas */
176
+
177
+ out vec2 v_texcoord;
178
+ flat out uint v_glyphLoc;
179
+ /* The spread in EM units for THIS instance. Flat, and a varying rather than a second uniform,
180
+ * because the object-to-em factor is a_emPerPos and that is per-instance: one uniform in em units
181
+ * could not mean the same number of pixels for two glyphs pushed at different pixelsPerEm. */
182
+ flat out float v_spreadEm;
183
+
184
+ void main ()
185
+ {
186
+ /* (-1, +1) -> (0, 0) and (+1, -1) -> (1, 1): the y term is flipped because a corner's outward
187
+ * normal points UP in object space exactly when it is the box's minimum em coordinate. */
188
+ vec2 corner = vec2 (a_normal.x, -a_normal.y) * 0.5 + 0.5;
189
+
190
+ vec2 pos = mix (a_position.xy, a_position.zw, corner);
191
+ vec2 tex = mix (a_texcoord.xy, a_texcoord.zw, corner);
192
+
193
+ vec4 jac = vec4 (a_emPerPos, 0.0, 0.0, -a_emPerPos);
194
+
195
+ /* At spread 0 both of these add 0.0, which is the identity for every finite value — the fill
196
+ * path really is byte-for-byte what it was before the outline path existed. */
197
+ vec2 spreadPos = a_normal * u_spreadPx;
198
+ pos += spreadPos;
199
+ tex += vec2 (dot (spreadPos, jac.xy), dot (spreadPos, jac.zw));
200
+
201
+ hb_gpu_dilate (pos, tex, a_normal, jac, u_matViewProjection, u_viewport);
202
+
203
+ gl_Position = u_matViewProjection * vec4 (pos, 0.0, 1.0);
204
+ v_texcoord = tex;
205
+ v_glyphLoc = a_glyphLoc;
206
+ v_spreadEm = u_spreadPx * a_emPerPos;
207
+ }
208
+ `;
209
+ /**
210
+ * OUR entry point for the fragment stage. Same ownership note as {@link VERTEX_MAIN}.
211
+ *
212
+ * STEM DARKENING AND GAMMA ARE HERE AND ON BY DEFAULT, and the thing that is now a MEASUREMENT
213
+ * decision is switching them OFF — see {@link HbGpuRendererOptions.contrast} and
214
+ * {@link HB_GPU_CONTRAST_NONE}. Both are contrast corrections that move coverage AWAY from correct
215
+ * area coverage, which is exactly what a fidelity arm grades against (`docs/text-rendering.md`: the
216
+ * reference is an 8x render box-downsampled, "a ceiling, not a mechanism"), so a HARNESS that left
217
+ * them on would be scoring its own contrast curve. A shipping consumer is not a harness: raw
218
+ * coverage puts a sub-pixel stem at mid-grey where a browser puts it near the ink colour, which is
219
+ * measurably why DOM text out-reads this path at small sizes.
220
+ *
221
+ * The block below is `util/gpu/demo-fragment.glsl`'s, with two differences that are ours:
222
+ * `brightness` comes off per-instance `v_color.rgb` directly (which is STRAIGHT, so upstream's divide by the
223
+ * premultiplied alpha is already done) and the ppem is HarfBuzz's own `hb_gpu_ppem` rather than
224
+ * `1.0 / max (fwidth (v_texcoord).xy)` — this file's render coordinates are FONT UNITS, so the
225
+ * reciprocal of their derivative is pixels per font unit and would put the `smoothstep (8, 48)`
226
+ * ramp a factor of `upem` off. `hb_gpu_ppem` folds the glyph's own scale in and is what
227
+ * `hb_gpu_spread_tap` above already agrees with.
228
+ *
229
+ * THE OUTLINE IS A MAX OF COVERAGE TAPS, BECAUSE THERE IS NO DISTANCE FIELD TO OFFSET. HarfBuzz's
230
+ * entire public GLSL surface is `hb_gpu_draw` (coverage), `hb_gpu_ppem` and `hb_gpu_stem_darken` —
231
+ * there is no signed distance anywhere in the format, so "dilate by r" cannot be a threshold shift
232
+ * and has to be "is any point within r of this one inside the glyph", sampled. See
233
+ * {@link HbGpuRenderer.setSpread} for what that costs and where it differs from a real stroke.
234
+ *
235
+ * EXPORTED SO THE TAP BUDGET CAN BE ASSERTED WITHOUT A GPU. `spreadBudget.test.ts` reads the
236
+ * interpolated {@link HB_GPU_SPREAD_MAX_TAPS} back out of this text; a compiled shader is the one
237
+ * place the constant has to be right, and the pixel suite that compiles it is gated on a display.
238
+ */
239
+ const BATCHED_FRAGMENT_MAIN = `
240
+ uniform float u_gamma; /* exponent on the final coverage; 1.0 is off */
241
+ uniform float u_stemDarken; /* > 0 runs hb_gpu_stem_darken; 0 is off */
242
+
243
+ in vec2 v_texcoord;
244
+ flat in uint v_glyphLoc;
245
+ flat in float v_spreadEm;
246
+ flat in float v_spreadPx;
247
+ flat in vec4 v_color;
248
+
249
+ out vec4 fragColor;
250
+
251
+ const float HB_GPU_SPREAD_TAU = 6.2831853;
252
+ /* Hard ceilings so the loop is bounded at compile time. One tap per iteration, so
253
+ * HB_GPU_SPREAD_MAX_TAPS + the centre tap is the worst case, and it is only reached by a fragment
254
+ * that is neither solid ink nor near any. See the TS constant of the same name. */
255
+ const int HB_GPU_SPREAD_MAX_RINGS = 4;
256
+ const int HB_GPU_SPREAD_MAX_TAPS = 64;
257
+ /* "Already saturated": no tap can raise this, so stop. Not 1.0, because the coverage estimator
258
+ * lands a hair under it on a deep-interior fragment and an exact test would never fire. */
259
+ const float HB_GPU_SPREAD_SOLID = 0.999;
260
+
261
+ /*
262
+ * WHERE A TAP STOPS MEANING "how much ink is at this offset" AND STARTS MEANING "is this fragment
263
+ * inside the dilated silhouette". The knee of a smoothstep, and the whole of the fix below.
264
+ *
265
+ * NO BACKTICKS ANYWHERE IN THIS COMMENT, and that is not a style note: this whole string is a JS
266
+ * template literal, so one backtick ends the shader mid-sentence and the package fails to PARSE.
267
+ *
268
+ * THE BUG IT REMOVES. A dilated shape is the union of a disk of radius r swept along the outline:
269
+ * a BINARY shape, whose only partial coverage is at its own boundary. A max of raw coverage taps
270
+ * cannot produce that, because a max cannot exceed the largest coverage near the fragment — and at
271
+ * ppem 14 a Han stroke is thinner than a pixel, so its coverage PEAKS at 0.42 and the whole
272
+ * silhouette came out a translucent mottle at 0.62 of the ideal's ink.
273
+ *
274
+ * THE RANGE IS 0 TO 0.5, AND "A TAP ABOVE HALF COVERAGE IS INSIDE" IS THE RULE THAT FAILS. That is
275
+ * the obvious reading and it makes this case measurably WORSE, which is why the knee is a swept
276
+ * number rather than an argued one. Half of a PIXEL is not half of a sub-pixel STROKE: at ppem 14
277
+ * the fixture's peak coverage is 0.42, so a knee centred on 0.5 sits above anything the glyph can
278
+ * reach and ERASES the outline. What 0.5 is the right value for is the top of the range — a pixel
279
+ * centred exactly ON the outline reads 0.5, so "as covered as a pixel on the boundary" is the point
280
+ * at which a tap is fully inside, and everything below it ramps.
281
+ *
282
+ * SWEPT ON THE RTX 2060 THROUGH ANGLE, both fixtures, against 8x grown references. Low-ppem is
283
+ * 中 at 14 px per em rotated 10 degrees, spread 3 (SHALLOW COVERAGE); thin is a full stop at 96 px
284
+ * per em, spread 12 (SPARSE COVERING — no 64-tap set tiles a disk of that radius; the sweep was run
285
+ * when those taps were four rings of 16, and the rim column moved again when they were resplit).
286
+ *
287
+ * knee low rim rms low ink ratio low interior short thin rim rms thin ink ratio
288
+ * none 77.89 0.622 38.5% 80.90 0.965
289
+ * 0.35 - 0.65 128.57 0.574 (worse still) 102.52 0.966
290
+ * 0.25 - 0.75 112.35 0.604 --- 99.72 0.965
291
+ * 0.20 - 0.50 76.87 0.870 --- 94.27 0.974
292
+ * 0.15 - 0.45 73.85 0.968 --- 90.15 0.978
293
+ * 0.10 - 0.40 84.75 1.037 --- 85.82 0.985
294
+ * 0.05 - 0.45 79.40 1.011 4.1% 82.16 0.986
295
+ * 0.05 - 0.50 72.64 0.968 7.4% 82.81 0.983
296
+ * 0.00 - 0.45 83.29 1.026 3.3% 78.86 0.990
297
+ * 0.00 - 0.55 69.73 0.947 9.3% 79.96 0.984
298
+ * 0.00 - 0.50 75.66 0.988 6.1% 79.10 0.987 <-
299
+ *
300
+ * The two upper rows are the "roughly half" hypothesis and both are worse than shipping nothing.
301
+ * 0 - 0.5 is the only row that improves EVERY column at once, and it is also the one with a
302
+ * sentence behind it rather than a fit.
303
+ *
304
+ * WHAT IT COSTS AT LARGE PPEM. A well-resolved glyph's tap coverage IS the area, so a tap sitting
305
+ * exactly on the outline reads 0.5 — and 0.5 is also the ideal answer at the dilated boundary,
306
+ * where this maps it to 1. So the boundary moves outward by a fraction of a pixel: measured, the
307
+ * 96 px per em ink box grows 5 px on one side for a spread of 4 instead of 4. Real, inside the
308
+ * fixtures' SPREAD_TOLERANCE_PX, and the price of an interior that is no longer translucent.
309
+ *
310
+ * PER TAP RATHER THAN ON THE MAX, AND NOT FOR THE REASON IT LOOKS LIKE. smoothstep is MONOTONE, so
311
+ * it commutes with max and the two placements give the same silhouette — measured, not reasoned:
312
+ * moving it after the loop reads ink ratio 0.989 against 0.988 and the same rim RMS to two decimal
313
+ * places. What the placement actually buys is the two things a monotone identity does not cover.
314
+ * First, the FILL IS THE FLOOR: cov enters the loop as hb_gpu_draw's own coverage and is never
315
+ * sharpened, so a dilated run stays a strict SUPERSET of the same run at spread 0 — sharpening the
316
+ * max would put the fill through the knee too, and smoothstep(0, 0.5, x) is BELOW x for x under
317
+ * ~0.08, so a faint fill pixel would come back dimmer than it was drawn. Second, the early-out
318
+ * below tests cov INSIDE the loop, and only a per-tap value can raise it early.
319
+ *
320
+ * IT DOES NOT MAKE THE EARLY-OUT FIRE AT 14 px, WHICH THE ROUND EXPECTED IT TO. A tap saturates to
321
+ * exactly 1 only once its raw coverage reaches HB_GPU_SPREAD_INSIDE_HIGH, and at ppem 14 the
322
+ * fixture's peak raw coverage is 0.42, which sharpens to 0.931 — still under
323
+ * HB_GPU_SPREAD_SOLID. So a fragment at that size still walks the whole tap set, and the frame-cost
324
+ * side effect that was predicted here IS NOT THERE. Above ppem 16 taps reached 1 before this change
325
+ * as well, so nothing moved there either. Lowering HB_GPU_SPREAD_SOLID would collect it, and is
326
+ * deliberately not done here: it is a cost decision with its own pixels to grade, on a rung where
327
+ * the tap budget is a device ceiling.
328
+ */
329
+ const float HB_GPU_SPREAD_INSIDE_LOW = 0.0;
330
+ const float HB_GPU_SPREAD_INSIDE_HIGH = 0.5;
331
+
332
+ /*
333
+ * One coverage tap, WITH NO DERIVATIVE IN IT — which is the whole reason this exists.
334
+ *
335
+ * It is _hb_gpu_slug (hb-gpu-fragment.glsl, 14.4.0) with ppem lifted into a parameter. The
336
+ * library's own _hb_gpu_slug advertises itself as callable "from non-uniform control flow", and
337
+ * for GLSL it is not: it calls hb_gpu_ppem, which calls fwidth. The disk below has a per-fragment
338
+ * early-out, so every tap after that point IS non-uniform control flow, and a fwidth there is
339
+ * undefined by GLSL ES 3.00.
340
+ *
341
+ * MIRRORED RATHER THAN AVOIDED so an outline tap and a fill fragment agree. Lifting ppem is exact
342
+ * rather than an approximation: it is fwidth(v_texcoord) and the glyph's own scale, and fwidth of an
343
+ * interpolated varying is constant across an affine quad, so its value at a tap equals its value at
344
+ * the centre.
345
+ *
346
+ * THE MSAA HALF IS SWITCHABLE AND THE DEFAULT IS OFF — see
347
+ * {@link HbGpuRendererOptions.spreadTapMsaa}, which carries the measurement. Note the macro is
348
+ * HB_GPU_SPREAD_TAP_NO_MSAA and NOT the library's HB_GPU_NO_MSAA: that one guards _hb_gpu_slug,
349
+ * i.e. the FILL, which this trade must not touch.
350
+ *
351
+ * The vendored wasm is digest-pinned (vendor/VENDOR.md, test/vendor.test.ts), so the source this
352
+ * mirrors cannot move without a deliberate vendor bump.
353
+ */
354
+ float hb_gpu_spread_tap (vec2 rc, vec2 pixelsPerEm, float ppem, uint glyphLoc_)
355
+ {
356
+ float c = _hb_gpu_slug_single (rc, pixelsPerEm, glyphLoc_);
357
+ #ifndef HB_GPU_SPREAD_TAP_NO_MSAA
358
+ if (ppem < 16.0)
359
+ {
360
+ vec2 emsPerPixel = 1.0 / pixelsPerEm;
361
+ vec2 d = emsPerPixel * (1.0 / 3.0);
362
+ float msaa = 0.25 *
363
+ (_hb_gpu_slug_single (rc + vec2 (-d.x, -d.y), pixelsPerEm, glyphLoc_) +
364
+ _hb_gpu_slug_single (rc + vec2 ( d.x, -d.y), pixelsPerEm, glyphLoc_) +
365
+ _hb_gpu_slug_single (rc + vec2 (-d.x, d.y), pixelsPerEm, glyphLoc_) +
366
+ _hb_gpu_slug_single (rc + vec2 ( d.x, d.y), pixelsPerEm, glyphLoc_));
367
+ c = mix (c, msaa, smoothstep (16.0, 8.0, ppem));
368
+ }
369
+ #endif
370
+ return c;
371
+ }
372
+
373
+ void main ()
374
+ {
375
+ float cov = hb_gpu_draw (v_texcoord, v_glyphLoc);
376
+
377
+ /* PER-PRIMITIVE CONTROL FLOW: the two derivative-taking calls inside see flat run state, so all
378
+ * fragments of one glyph primitive take the same branch. v_spreadPx and v_spreadEm are flat
379
+ * (constant over a primitive, which is what a derivative
380
+ * quad belongs to), so it is uniform by construction rather than by luck. v_spreadEm is also a
381
+ * genuine guard: a NaN or zero a_emPerPos fails it and takes the single-tap path. */
382
+ if (v_spreadPx > 0.0 && v_spreadEm > 0.0)
383
+ {
384
+ vec2 pixelsPerEm = 1.0 / fwidth (v_texcoord);
385
+ float ppem = hb_gpu_ppem (v_texcoord, v_glyphLoc);
386
+ /* The spread in DEVICE pixels — fwidth is a screen-space derivative, so this already carries
387
+ * the model scale and the device-pixel ratio. It only ever picks tap counts; the tap OFFSETS
388
+ * are in em units and are exact. */
389
+ float radiusPx = v_spreadEm * max (pixelsPerEm.x, pixelsPerEm.y);
390
+
391
+ /* CONCENTRIC RINGS, NOT ONE, and that is not a refinement. A dilated fragment is covered iff
392
+ * SOME offset within the disk lands on ink; taps on a single ring of radius r can all overshoot
393
+ * a feature narrower than 2r, which punches holes through the outline exactly where a glyph is
394
+ * thin — a comma, a hairline serif, a full stop. Ring spacing is held near 2/3 px, so the RADIAL
395
+ * half of the covering is sub-pixel out to the clamp at HB_GPU_SPREAD_MAX_RINGS.
396
+ *
397
+ * THE RADII STAY EQUALLY SPACED, which is worth saying because equal AREA is the obvious
398
+ * alternative and it is worse here. Pushing the rings outward concentrates them where the taps
399
+ * are already densest, and combined with the budget split below it both doubles the outward bias
400
+ * of the whole tap set and halves the radial margin the small-feature case relies on — the one
401
+ * where a full stop smaller than the tap radius has to be found by an INNER ring. */
402
+ int rings = clamp (int (ceil (radiusPx * 1.5)), 1, HB_GPU_SPREAD_MAX_RINGS);
403
+ /* 1 + 2 + ... + rings, the denominator of the budget split below. */
404
+ int denom = rings * (rings + 1) / 2;
405
+
406
+ /* ONE FLAT LOOP OVER THE WHOLE BUDGET, AND THE OUTER RING GETS MOST OF IT.
407
+ *
408
+ * Every ring used to be capped at the same number of steps, which sounds neutral and is not: a
409
+ * ring's taps are spread over a circumference proportional to its radius, so an equal share puts
410
+ * the WIDEST arc gaps on the outermost ring — the only one that decides where the dilated
411
+ * boundary lands. At radius 12 that was 4.71 px of arc between the taps that draw the edge,
412
+ * against 1.18 px on ring 1, and the boundary followed the tap count: measured against a Godot
413
+ * 4.5.1 golden, 0.3099 px of wobble at exactly 16 cycles per revolution where the engine has
414
+ * 0.0164.
415
+ *
416
+ * So the budget is split in proportion to ring RADIUS, i.e. to circumference: ring k of rings
417
+ * may spend (MAX_TAPS * k + denom/2) / denom taps, which at four rings is 6 / 13 / 19 / 26 and
418
+ * sums to exactly MAX_TAPS. It sums to exactly MAX_TAPS at one, two and three rings as well
419
+ * (64; 21 + 43; 11 + 21 + 32), so the flat bound is never the thing that truncates a ring — it
420
+ * is a hedge against a driver that insists on unrolling, not a second policy. At radius 12 the
421
+ * outer arc is then 2.90 px rather than 4.71.
422
+ *
423
+ * The lower clamp of 6 steps is what keeps a SMALL radius honest, and it is the reason the cap
424
+ * enters as max (cap, 6) rather than as cap: ring 1's share at four rings is exactly 6, and a
425
+ * hexagon is the coarsest ring that still surrounds its centre.
426
+ *
427
+ * ONE TAP PER ITERATION, so HB_GPU_SPREAD_MAX_TAPS is simultaneously the loop bound and the
428
+ * fragment's worst-case cost — the two used to be 4 x 16 and 65 and had to be reasoned about
429
+ * separately. Dynamic bounds and breaks are legal ESSL 3.00; the GLSL ES 1.00 Appendix A
430
+ * restriction that forced the nested constant shape does not apply to version 300 es. */
431
+ int ring = 0;
432
+ int step = 0;
433
+ int steps = 0;
434
+ float ringEm = 0.0;
435
+ float phase = 0.0;
436
+ for (int i = 0; i < HB_GPU_SPREAD_MAX_TAPS; i++)
437
+ {
438
+ /* THE INTERIOR EARLY-OUT: a fragment already covered by its own centre tap is trivially
439
+ * within r of ink, and interior fragments are most of a glyph. */
440
+ if (cov >= HB_GPU_SPREAD_SOLID) break;
441
+ if (step >= steps)
442
+ {
443
+ ring += 1;
444
+ /* The other exit: the rings this radius actually asked for are done. */
445
+ if (ring > rings) break;
446
+ float t = float (ring) / float (rings);
447
+ ringEm = v_spreadEm * t;
448
+ int cap = (HB_GPU_SPREAD_MAX_TAPS * ring + denom / 2) / denom;
449
+ steps = clamp (int (ceil (HB_GPU_SPREAD_TAU * radiusPx * t)), 6, max (cap, 6));
450
+ /* THE GOLDEN ANGLE, so no two rings put their taps on the same radii — which would leave
451
+ * wedge-shaped gaps between the rings rather than a covering. A fixed fraction of a step
452
+ * would do that for one pair of ring counts and line up for another; 137.5 degrees per ring
453
+ * is the rotation with no small-integer commensurability with any of them. */
454
+ phase = 2.39996 * float (ring);
455
+ step = 0;
456
+ }
457
+ float angle = phase + HB_GPU_SPREAD_TAU * float (step) / float (steps);
458
+ vec2 at = v_texcoord + ringEm * vec2 (cos (angle), sin (angle));
459
+ /* MAX, NOT A SUM, AND THE MAX IS WHY THIS IS IN THE SHADER. The alternative a caller could
460
+ * build without it — draw the run N times at N offsets — composites N times, so a
461
+ * translucent outline is N overlapping translucent copies and reads far darker than one
462
+ * stroke. One fragment, one coverage, one blend.
463
+ *
464
+ * SHARPENED BEFORE THE MAX, not after: the max is over a set of INSIDE tests, and the union
465
+ * of disks it approximates is a binary shape. Sharpening the max instead would sharpen a
466
+ * number that had already been flattened to the peak coverage nearby, which is the value
467
+ * that is wrong. See HB_GPU_SPREAD_INSIDE_LOW. */
468
+ cov = max (cov, smoothstep (HB_GPU_SPREAD_INSIDE_LOW,
469
+ HB_GPU_SPREAD_INSIDE_HIGH,
470
+ hb_gpu_spread_tap (at, pixelsPerEm, ppem, v_glyphLoc)));
471
+ step += 1;
472
+ }
473
+ }
474
+
475
+ /* CONTRAST, ON THE FINAL COVERAGE — and STEM DARKENING ONLY WHEN THE DILATION DID NOT RUN.
476
+ *
477
+ * An outline's rim is NOT a coverage ramp with the same problem the fill's has, which is what
478
+ * this block assumed when it shipped. Stem darkening exists because a sub-pixel STEM lands at
479
+ * mid-grey under linear coverage where a browser puts it near the ink colour; the fix is an
480
+ * exponent that pushes the middle of the ramp toward the foreground. A dilated fragment's
481
+ * coverage is not that number. The taps above are an INSIDE test (HB_GPU_SPREAD_INSIDE_LOW
482
+ * sharpens each one before the max), so the dilated boundary is already very nearly binary — and
483
+ * the engine this mirrors applies no curve at all to its outline: Godot strokes the glyph and
484
+ * hands the result to FreeType's plain grayscale raster. Measured against that golden, the curve
485
+ * took a 14 px outline's rim from 0.662 px to 1.965 px of equivalent ramp, three times the width,
486
+ * where Godot's own is 0.851. That is a halo — fattening for dark ink, thinning for light — and
487
+ * it is the one thing in this shader that made an outline softer than the engine's.
488
+ *
489
+ * THE GATE IS THE DILATION BRANCH'S OWN CONDITION, CHARACTER FOR CHARACTER, so the two cannot
490
+ * disagree about which fragments dilated. A bare v_spreadPx > 0.0 would also strip the
491
+ * correction from a DEGENERATE run — one whose a_emPerPos is zero or NaN, which fails
492
+ * v_spreadEm > 0.0 and takes the single-tap path. Such a fragment is a fill in every way that
493
+ * reaches the framebuffer, and it should keep a fill's darkening rather than lose it to a uniform
494
+ * that ended up doing nothing.
495
+ *
496
+ * GAMMA IS NOT GATED. It is an explicit consumer knob, defaults to 1 (skipped entirely below),
497
+ * and is polarity- and size-blind by construction; a consumer that deliberately sets one is
498
+ * asking for a transfer curve on the text, and putting the fill and the outline of the same run
499
+ * on different curves would be a stranger thing than either choice.
500
+ *
501
+ * THE PPEM IS FETCHED IN UNIFORM CONTROL FLOW AND THE COVERAGE TEST IS NOT ALLOWED TO CONTAIN IT.
502
+ * hb_gpu_ppem calls fwidth, which GLSL ES 3.00 leaves undefined once the 2x2 derivative quad can
503
+ * disagree about whether to run it — and "0.0 < cov < 1.0" is precisely a per-fragment condition.
504
+ * u_stemDarken is uniform while v_spreadPx and v_spreadEm are flat, which is the same
505
+ * argument the dilation branch above makes for its own hb_gpu_ppem call; darken is that branch's
506
+ * predicate negated, so a quad coherent enough to run the dilation is exactly as coherent about
507
+ * skipping the darkening. Upstream's demo puts its fwidth inside the coverage test; that is a
508
+ * desktop-GLSL liberty this cannot take.
509
+ */
510
+ bool fillPass = !(v_spreadPx > 0.0 && v_spreadEm > 0.0);
511
+ bool darken = u_stemDarken > 0.0 && fillPass;
512
+
513
+ float darkenPpem = 0.0;
514
+ if (darken)
515
+ darkenPpem = hb_gpu_ppem (v_texcoord, v_glyphLoc);
516
+
517
+ /* EDGE ONLY, which is upstream's guard and is not merely an optimisation here. Both corrections
518
+ * fix 0 and 1, so the interior and the background cannot move whatever the exponents are — but
519
+ * pow (0.0, y) is UNDEFINED for y <= 0 in GLSL ES 3.00, and u_gamma is a number a consumer chose.
520
+ * Skipping cov == 0.0 is what makes a hostile gamma a picture that is wrong rather than a NaN
521
+ * alpha over the whole quad. No derivative is taken inside, so a non-uniform branch is legal. */
522
+ if (cov > 0.0 && cov < 1.0)
523
+ {
524
+ float adj = cov;
525
+ if (darken)
526
+ {
527
+ /* v_color is STRAIGHT, so this IS upstream's dot (c.rgb, 1/3) / c.a with the divide already
528
+ * done. A flat 1/3 rather than a Rec.709 luma on purpose: hb_gpu_stem_darken's exponent
529
+ * curve is calibrated against HarfBuzz's own definition of "brightness", and weighting the
530
+ * channels differently would silently retune somebody else's constants. */
531
+ adj = hb_gpu_stem_darken (adj, dot (v_color.rgb, vec3 (1.0 / 3.0)), darkenPpem);
532
+ }
533
+ if (u_gamma != 1.0)
534
+ adj = pow (adj, u_gamma);
535
+ cov = adj;
536
+ }
537
+
538
+ float a = v_color.a * cov;
539
+ fragColor = vec4 (v_color.rgb * a, a);
540
+ }
541
+ `;
542
+ const FRAGMENT_MAIN = `
543
+ uniform vec4 u_color; /* STRAIGHT rgba; premultiplied exactly once, below */
544
+ uniform float u_spreadPx;
545
+ uniform float u_gamma; /* exponent on the final coverage; 1.0 is off */
546
+ uniform float u_stemDarken; /* > 0 runs hb_gpu_stem_darken; 0 is off */
547
+
548
+ in vec2 v_texcoord;
549
+ flat in uint v_glyphLoc;
550
+ flat in float v_spreadEm;
551
+
552
+ out vec4 fragColor;
553
+
554
+ const float HB_GPU_SPREAD_TAU = 6.2831853;
555
+ /* Hard ceilings so the loop is bounded at compile time. One tap per iteration, so
556
+ * HB_GPU_SPREAD_MAX_TAPS + the centre tap is the worst case, and it is only reached by a fragment
557
+ * that is neither solid ink nor near any. See the TS constant of the same name. */
558
+ const int HB_GPU_SPREAD_MAX_RINGS = 4;
559
+ const int HB_GPU_SPREAD_MAX_TAPS = 64;
560
+ /* "Already saturated": no tap can raise this, so stop. Not 1.0, because the coverage estimator
561
+ * lands a hair under it on a deep-interior fragment and an exact test would never fire. */
562
+ const float HB_GPU_SPREAD_SOLID = 0.999;
563
+
564
+ /*
565
+ * WHERE A TAP STOPS MEANING "how much ink is at this offset" AND STARTS MEANING "is this fragment
566
+ * inside the dilated silhouette". The knee of a smoothstep, and the whole of the fix below.
567
+ *
568
+ * NO BACKTICKS ANYWHERE IN THIS COMMENT, and that is not a style note: this whole string is a JS
569
+ * template literal, so one backtick ends the shader mid-sentence and the package fails to PARSE.
570
+ *
571
+ * THE BUG IT REMOVES. A dilated shape is the union of a disk of radius r swept along the outline:
572
+ * a BINARY shape, whose only partial coverage is at its own boundary. A max of raw coverage taps
573
+ * cannot produce that, because a max cannot exceed the largest coverage near the fragment — and at
574
+ * ppem 14 a Han stroke is thinner than a pixel, so its coverage PEAKS at 0.42 and the whole
575
+ * silhouette came out a translucent mottle at 0.62 of the ideal's ink.
576
+ *
577
+ * THE RANGE IS 0 TO 0.5, AND "A TAP ABOVE HALF COVERAGE IS INSIDE" IS THE RULE THAT FAILS. That is
578
+ * the obvious reading and it makes this case measurably WORSE, which is why the knee is a swept
579
+ * number rather than an argued one. Half of a PIXEL is not half of a sub-pixel STROKE: at ppem 14
580
+ * the fixture's peak coverage is 0.42, so a knee centred on 0.5 sits above anything the glyph can
581
+ * reach and ERASES the outline. What 0.5 is the right value for is the top of the range — a pixel
582
+ * centred exactly ON the outline reads 0.5, so "as covered as a pixel on the boundary" is the point
583
+ * at which a tap is fully inside, and everything below it ramps.
584
+ *
585
+ * SWEPT ON THE RTX 2060 THROUGH ANGLE, both fixtures, against 8x grown references. Low-ppem is
586
+ * 中 at 14 px per em rotated 10 degrees, spread 3 (SHALLOW COVERAGE); thin is a full stop at 96 px
587
+ * per em, spread 12 (SPARSE COVERING — no 64-tap set tiles a disk of that radius; the sweep was run
588
+ * when those taps were four rings of 16, and the rim column moved again when they were resplit).
589
+ *
590
+ * knee low rim rms low ink ratio low interior short thin rim rms thin ink ratio
591
+ * none 77.89 0.622 38.5% 80.90 0.965
592
+ * 0.35 - 0.65 128.57 0.574 (worse still) 102.52 0.966
593
+ * 0.25 - 0.75 112.35 0.604 --- 99.72 0.965
594
+ * 0.20 - 0.50 76.87 0.870 --- 94.27 0.974
595
+ * 0.15 - 0.45 73.85 0.968 --- 90.15 0.978
596
+ * 0.10 - 0.40 84.75 1.037 --- 85.82 0.985
597
+ * 0.05 - 0.45 79.40 1.011 4.1% 82.16 0.986
598
+ * 0.05 - 0.50 72.64 0.968 7.4% 82.81 0.983
599
+ * 0.00 - 0.45 83.29 1.026 3.3% 78.86 0.990
600
+ * 0.00 - 0.55 69.73 0.947 9.3% 79.96 0.984
601
+ * 0.00 - 0.50 75.66 0.988 6.1% 79.10 0.987 <-
602
+ *
603
+ * The two upper rows are the "roughly half" hypothesis and both are worse than shipping nothing.
604
+ * 0 - 0.5 is the only row that improves EVERY column at once, and it is also the one with a
605
+ * sentence behind it rather than a fit.
606
+ *
607
+ * WHAT IT COSTS AT LARGE PPEM. A well-resolved glyph's tap coverage IS the area, so a tap sitting
608
+ * exactly on the outline reads 0.5 — and 0.5 is also the ideal answer at the dilated boundary,
609
+ * where this maps it to 1. So the boundary moves outward by a fraction of a pixel: measured, the
610
+ * 96 px per em ink box grows 5 px on one side for a spread of 4 instead of 4. Real, inside the
611
+ * fixtures' SPREAD_TOLERANCE_PX, and the price of an interior that is no longer translucent.
612
+ *
613
+ * PER TAP RATHER THAN ON THE MAX, AND NOT FOR THE REASON IT LOOKS LIKE. smoothstep is MONOTONE, so
614
+ * it commutes with max and the two placements give the same silhouette — measured, not reasoned:
615
+ * moving it after the loop reads ink ratio 0.989 against 0.988 and the same rim RMS to two decimal
616
+ * places. What the placement actually buys is the two things a monotone identity does not cover.
617
+ * First, the FILL IS THE FLOOR: cov enters the loop as hb_gpu_draw's own coverage and is never
618
+ * sharpened, so a dilated run stays a strict SUPERSET of the same run at spread 0 — sharpening the
619
+ * max would put the fill through the knee too, and smoothstep(0, 0.5, x) is BELOW x for x under
620
+ * ~0.08, so a faint fill pixel would come back dimmer than it was drawn. Second, the early-out
621
+ * below tests cov INSIDE the loop, and only a per-tap value can raise it early.
622
+ *
623
+ * IT DOES NOT MAKE THE EARLY-OUT FIRE AT 14 px, WHICH THE ROUND EXPECTED IT TO. A tap saturates to
624
+ * exactly 1 only once its raw coverage reaches HB_GPU_SPREAD_INSIDE_HIGH, and at ppem 14 the
625
+ * fixture's peak raw coverage is 0.42, which sharpens to 0.931 — still under
626
+ * HB_GPU_SPREAD_SOLID. So a fragment at that size still walks the whole tap set, and the frame-cost
627
+ * side effect that was predicted here IS NOT THERE. Above ppem 16 taps reached 1 before this change
628
+ * as well, so nothing moved there either. Lowering HB_GPU_SPREAD_SOLID would collect it, and is
629
+ * deliberately not done here: it is a cost decision with its own pixels to grade, on a rung where
630
+ * the tap budget is a device ceiling.
631
+ */
632
+ const float HB_GPU_SPREAD_INSIDE_LOW = 0.0;
633
+ const float HB_GPU_SPREAD_INSIDE_HIGH = 0.5;
634
+
635
+ /*
636
+ * One coverage tap, WITH NO DERIVATIVE IN IT — which is the whole reason this exists.
637
+ *
638
+ * It is _hb_gpu_slug (hb-gpu-fragment.glsl, 14.4.0) with ppem lifted into a parameter. The
639
+ * library's own _hb_gpu_slug advertises itself as callable "from non-uniform control flow", and
640
+ * for GLSL it is not: it calls hb_gpu_ppem, which calls fwidth. The disk below has a per-fragment
641
+ * early-out, so every tap after that point IS non-uniform control flow, and a fwidth there is
642
+ * undefined by GLSL ES 3.00.
643
+ *
644
+ * MIRRORED RATHER THAN AVOIDED so an outline tap and a fill fragment agree. Lifting ppem is exact
645
+ * rather than an approximation: it is fwidth(v_texcoord) and the glyph's own scale, and fwidth of an
646
+ * interpolated varying is constant across an affine quad, so its value at a tap equals its value at
647
+ * the centre.
648
+ *
649
+ * THE MSAA HALF IS SWITCHABLE AND THE DEFAULT IS OFF — see
650
+ * {@link HbGpuRendererOptions.spreadTapMsaa}, which carries the measurement. Note the macro is
651
+ * HB_GPU_SPREAD_TAP_NO_MSAA and NOT the library's HB_GPU_NO_MSAA: that one guards _hb_gpu_slug,
652
+ * i.e. the FILL, which this trade must not touch.
653
+ *
654
+ * The vendored wasm is digest-pinned (vendor/VENDOR.md, test/vendor.test.ts), so the source this
655
+ * mirrors cannot move without a deliberate vendor bump.
656
+ */
657
+ float hb_gpu_spread_tap (vec2 rc, vec2 pixelsPerEm, float ppem, uint glyphLoc_)
658
+ {
659
+ float c = _hb_gpu_slug_single (rc, pixelsPerEm, glyphLoc_);
660
+ #ifndef HB_GPU_SPREAD_TAP_NO_MSAA
661
+ if (ppem < 16.0)
662
+ {
663
+ vec2 emsPerPixel = 1.0 / pixelsPerEm;
664
+ vec2 d = emsPerPixel * (1.0 / 3.0);
665
+ float msaa = 0.25 *
666
+ (_hb_gpu_slug_single (rc + vec2 (-d.x, -d.y), pixelsPerEm, glyphLoc_) +
667
+ _hb_gpu_slug_single (rc + vec2 ( d.x, -d.y), pixelsPerEm, glyphLoc_) +
668
+ _hb_gpu_slug_single (rc + vec2 (-d.x, d.y), pixelsPerEm, glyphLoc_) +
669
+ _hb_gpu_slug_single (rc + vec2 ( d.x, d.y), pixelsPerEm, glyphLoc_));
670
+ c = mix (c, msaa, smoothstep (16.0, 8.0, ppem));
671
+ }
672
+ #endif
673
+ return c;
674
+ }
675
+
676
+ void main ()
677
+ {
678
+ float cov = hb_gpu_draw (v_texcoord, v_glyphLoc);
679
+
680
+ /* UNIFORM CONTROL FLOW, AND IT HAS TO BE: the two derivative-taking calls inside are legal only
681
+ * because every fragment of a 2x2 derivative quad takes this branch together. u_spreadPx is a
682
+ * real uniform and v_spreadEm is flat (constant over a primitive, which is what a derivative
683
+ * quad belongs to), so it is uniform by construction rather than by luck. v_spreadEm is also a
684
+ * genuine guard: a NaN or zero a_emPerPos fails it and takes the single-tap path. */
685
+ if (u_spreadPx > 0.0 && v_spreadEm > 0.0)
686
+ {
687
+ vec2 pixelsPerEm = 1.0 / fwidth (v_texcoord);
688
+ float ppem = hb_gpu_ppem (v_texcoord, v_glyphLoc);
689
+ /* The spread in DEVICE pixels — fwidth is a screen-space derivative, so this already carries
690
+ * the model scale and the device-pixel ratio. It only ever picks tap counts; the tap OFFSETS
691
+ * are in em units and are exact. */
692
+ float radiusPx = v_spreadEm * max (pixelsPerEm.x, pixelsPerEm.y);
693
+
694
+ /* CONCENTRIC RINGS, NOT ONE, and that is not a refinement. A dilated fragment is covered iff
695
+ * SOME offset within the disk lands on ink; taps on a single ring of radius r can all overshoot
696
+ * a feature narrower than 2r, which punches holes through the outline exactly where a glyph is
697
+ * thin — a comma, a hairline serif, a full stop. Ring spacing is held near 2/3 px, so the RADIAL
698
+ * half of the covering is sub-pixel out to the clamp at HB_GPU_SPREAD_MAX_RINGS.
699
+ *
700
+ * THE RADII STAY EQUALLY SPACED, which is worth saying because equal AREA is the obvious
701
+ * alternative and it is worse here. Pushing the rings outward concentrates them where the taps
702
+ * are already densest, and combined with the budget split below it both doubles the outward bias
703
+ * of the whole tap set and halves the radial margin the small-feature case relies on — the one
704
+ * where a full stop smaller than the tap radius has to be found by an INNER ring. */
705
+ int rings = clamp (int (ceil (radiusPx * 1.5)), 1, HB_GPU_SPREAD_MAX_RINGS);
706
+ /* 1 + 2 + ... + rings, the denominator of the budget split below. */
707
+ int denom = rings * (rings + 1) / 2;
708
+
709
+ /* ONE FLAT LOOP OVER THE WHOLE BUDGET, AND THE OUTER RING GETS MOST OF IT.
710
+ *
711
+ * Every ring used to be capped at the same number of steps, which sounds neutral and is not: a
712
+ * ring's taps are spread over a circumference proportional to its radius, so an equal share puts
713
+ * the WIDEST arc gaps on the outermost ring — the only one that decides where the dilated
714
+ * boundary lands. At radius 12 that was 4.71 px of arc between the taps that draw the edge,
715
+ * against 1.18 px on ring 1, and the boundary followed the tap count: measured against a Godot
716
+ * 4.5.1 golden, 0.3099 px of wobble at exactly 16 cycles per revolution where the engine has
717
+ * 0.0164.
718
+ *
719
+ * So the budget is split in proportion to ring RADIUS, i.e. to circumference: ring k of rings
720
+ * may spend (MAX_TAPS * k + denom/2) / denom taps, which at four rings is 6 / 13 / 19 / 26 and
721
+ * sums to exactly MAX_TAPS. It sums to exactly MAX_TAPS at one, two and three rings as well
722
+ * (64; 21 + 43; 11 + 21 + 32), so the flat bound is never the thing that truncates a ring — it
723
+ * is a hedge against a driver that insists on unrolling, not a second policy. At radius 12 the
724
+ * outer arc is then 2.90 px rather than 4.71.
725
+ *
726
+ * The lower clamp of 6 steps is what keeps a SMALL radius honest, and it is the reason the cap
727
+ * enters as max (cap, 6) rather than as cap: ring 1's share at four rings is exactly 6, and a
728
+ * hexagon is the coarsest ring that still surrounds its centre.
729
+ *
730
+ * ONE TAP PER ITERATION, so HB_GPU_SPREAD_MAX_TAPS is simultaneously the loop bound and the
731
+ * fragment's worst-case cost — the two used to be 4 x 16 and 65 and had to be reasoned about
732
+ * separately. Dynamic bounds and breaks are legal ESSL 3.00; the GLSL ES 1.00 Appendix A
733
+ * restriction that forced the nested constant shape does not apply to version 300 es. */
734
+ int ring = 0;
735
+ int step = 0;
736
+ int steps = 0;
737
+ float ringEm = 0.0;
738
+ float phase = 0.0;
739
+ for (int i = 0; i < HB_GPU_SPREAD_MAX_TAPS; i++)
740
+ {
741
+ /* THE INTERIOR EARLY-OUT: a fragment already covered by its own centre tap is trivially
742
+ * within r of ink, and interior fragments are most of a glyph. */
743
+ if (cov >= HB_GPU_SPREAD_SOLID) break;
744
+ if (step >= steps)
745
+ {
746
+ ring += 1;
747
+ /* The other exit: the rings this radius actually asked for are done. */
748
+ if (ring > rings) break;
749
+ float t = float (ring) / float (rings);
750
+ ringEm = v_spreadEm * t;
751
+ int cap = (HB_GPU_SPREAD_MAX_TAPS * ring + denom / 2) / denom;
752
+ steps = clamp (int (ceil (HB_GPU_SPREAD_TAU * radiusPx * t)), 6, max (cap, 6));
753
+ /* THE GOLDEN ANGLE, so no two rings put their taps on the same radii — which would leave
754
+ * wedge-shaped gaps between the rings rather than a covering. A fixed fraction of a step
755
+ * would do that for one pair of ring counts and line up for another; 137.5 degrees per ring
756
+ * is the rotation with no small-integer commensurability with any of them. */
757
+ phase = 2.39996 * float (ring);
758
+ step = 0;
759
+ }
760
+ float angle = phase + HB_GPU_SPREAD_TAU * float (step) / float (steps);
761
+ vec2 at = v_texcoord + ringEm * vec2 (cos (angle), sin (angle));
762
+ /* MAX, NOT A SUM, AND THE MAX IS WHY THIS IS IN THE SHADER. The alternative a caller could
763
+ * build without it — draw the run N times at N offsets — composites N times, so a
764
+ * translucent outline is N overlapping translucent copies and reads far darker than one
765
+ * stroke. One fragment, one coverage, one blend.
766
+ *
767
+ * SHARPENED BEFORE THE MAX, not after: the max is over a set of INSIDE tests, and the union
768
+ * of disks it approximates is a binary shape. Sharpening the max instead would sharpen a
769
+ * number that had already been flattened to the peak coverage nearby, which is the value
770
+ * that is wrong. See HB_GPU_SPREAD_INSIDE_LOW. */
771
+ cov = max (cov, smoothstep (HB_GPU_SPREAD_INSIDE_LOW,
772
+ HB_GPU_SPREAD_INSIDE_HIGH,
773
+ hb_gpu_spread_tap (at, pixelsPerEm, ppem, v_glyphLoc)));
774
+ step += 1;
775
+ }
776
+ }
777
+
778
+ /* CONTRAST, ON THE FINAL COVERAGE — and STEM DARKENING ONLY WHEN THE DILATION DID NOT RUN.
779
+ *
780
+ * An outline's rim is NOT a coverage ramp with the same problem the fill's has, which is what
781
+ * this block assumed when it shipped. Stem darkening exists because a sub-pixel STEM lands at
782
+ * mid-grey under linear coverage where a browser puts it near the ink colour; the fix is an
783
+ * exponent that pushes the middle of the ramp toward the foreground. A dilated fragment's
784
+ * coverage is not that number. The taps above are an INSIDE test (HB_GPU_SPREAD_INSIDE_LOW
785
+ * sharpens each one before the max), so the dilated boundary is already very nearly binary — and
786
+ * the engine this mirrors applies no curve at all to its outline: Godot strokes the glyph and
787
+ * hands the result to FreeType's plain grayscale raster. Measured against that golden, the curve
788
+ * took a 14 px outline's rim from 0.662 px to 1.965 px of equivalent ramp, three times the width,
789
+ * where Godot's own is 0.851. That is a halo — fattening for dark ink, thinning for light — and
790
+ * it is the one thing in this shader that made an outline softer than the engine's.
791
+ *
792
+ * THE GATE IS THE DILATION BRANCH'S OWN CONDITION, CHARACTER FOR CHARACTER, so the two cannot
793
+ * disagree about which fragments dilated. A bare u_spreadPx > 0.0 would also strip the
794
+ * correction from a DEGENERATE run — one whose a_emPerPos is zero or NaN, which fails
795
+ * v_spreadEm > 0.0 and takes the single-tap path. Such a fragment is a fill in every way that
796
+ * reaches the framebuffer, and it should keep a fill's darkening rather than lose it to a uniform
797
+ * that ended up doing nothing.
798
+ *
799
+ * GAMMA IS NOT GATED. It is an explicit consumer knob, defaults to 1 (skipped entirely below),
800
+ * and is polarity- and size-blind by construction; a consumer that deliberately sets one is
801
+ * asking for a transfer curve on the text, and putting the fill and the outline of the same run
802
+ * on different curves would be a stranger thing than either choice.
803
+ *
804
+ * THE PPEM IS FETCHED IN UNIFORM CONTROL FLOW AND THE COVERAGE TEST IS NOT ALLOWED TO CONTAIN IT.
805
+ * hb_gpu_ppem calls fwidth, which GLSL ES 3.00 leaves undefined once the 2x2 derivative quad can
806
+ * disagree about whether to run it — and "0.0 < cov < 1.0" is precisely a per-fragment condition.
807
+ * u_stemDarken and u_spreadPx are real uniforms and v_spreadEm is flat, which is the same
808
+ * argument the dilation branch above makes for its own hb_gpu_ppem call; darken is that branch's
809
+ * predicate negated, so a quad coherent enough to run the dilation is exactly as coherent about
810
+ * skipping the darkening. Upstream's demo puts its fwidth inside the coverage test; that is a
811
+ * desktop-GLSL liberty this cannot take.
812
+ */
813
+ bool fillPass = !(u_spreadPx > 0.0 && v_spreadEm > 0.0);
814
+ bool darken = u_stemDarken > 0.0 && fillPass;
815
+
816
+ float darkenPpem = 0.0;
817
+ if (darken)
818
+ darkenPpem = hb_gpu_ppem (v_texcoord, v_glyphLoc);
819
+
820
+ /* EDGE ONLY, which is upstream's guard and is not merely an optimisation here. Both corrections
821
+ * fix 0 and 1, so the interior and the background cannot move whatever the exponents are — but
822
+ * pow (0.0, y) is UNDEFINED for y <= 0 in GLSL ES 3.00, and u_gamma is a number a consumer chose.
823
+ * Skipping cov == 0.0 is what makes a hostile gamma a picture that is wrong rather than a NaN
824
+ * alpha over the whole quad. No derivative is taken inside, so a non-uniform branch is legal. */
825
+ if (cov > 0.0 && cov < 1.0)
826
+ {
827
+ float adj = cov;
828
+ if (darken)
829
+ {
830
+ /* u_color is STRAIGHT, so this IS upstream's dot (c.rgb, 1/3) / c.a with the divide already
831
+ * done. A flat 1/3 rather than a Rec.709 luma on purpose: hb_gpu_stem_darken's exponent
832
+ * curve is calibrated against HarfBuzz's own definition of "brightness", and weighting the
833
+ * channels differently would silently retune somebody else's constants. */
834
+ adj = hb_gpu_stem_darken (adj, dot (u_color.rgb, vec3 (1.0 / 3.0)), darkenPpem);
835
+ }
836
+ if (u_gamma != 1.0)
837
+ adj = pow (adj, u_gamma);
838
+ cov = adj;
839
+ }
840
+
841
+ float a = u_color.a * cov;
842
+ fragColor = vec4 (u_color.rgb * a, a);
843
+ }
844
+ `;
845
+ /** Stem darkening on, gamma neutral. What a renderer built without a `contrast` option gets. */
846
+ const HB_GPU_CONTRAST_DEFAULT = Object.freeze({
847
+ gamma: 1,
848
+ stemDarkening: true
849
+ });
850
+ /**
851
+ * No contrast curve at all: the fragment writes the coverage it computed.
852
+ *
853
+ * FOR MEASUREMENT ARMS, and they should say so where they pass it. A fidelity probe that grades an
854
+ * arm against an 8x area-coverage reference is grading the RASTERIZER, and an arm carrying a
855
+ * contrast curve scores the curve instead — `docs/text-rendering.md`'s distortion figures (0.196
856
+ * Han at ppem 14, 0.017 at ppem 49) only mean what they say against raw coverage.
857
+ */
858
+ const HB_GPU_CONTRAST_NONE = Object.freeze({
859
+ gamma: 1,
860
+ stemDarkening: false
861
+ });
862
+ /** A compiled shader, or the reason there is not one. Never a throw — see `createHbGpuRenderer`. */
863
+ function compileShader(gl, type, source) {
864
+ const stage = type === gl.VERTEX_SHADER ? "vertex" : "fragment";
865
+ const shader = gl.createShader(type);
866
+ if (!shader) return {
867
+ reason: "gl-object",
868
+ message: `gl.createShader(${stage}) returned null — the context is lost or out of resources`
869
+ };
870
+ gl.shaderSource(shader, source);
871
+ gl.compileShader(shader);
872
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
873
+ const log = gl.getShaderInfoLog(shader) || "(no log)";
874
+ gl.deleteShader(shader);
875
+ return {
876
+ reason: "shader-compile",
877
+ message: `${stage} shader failed to compile — ${log}`
878
+ };
879
+ }
880
+ return { shader };
881
+ }
882
+ /**
883
+ * A WebGL2 renderer for hb-gpu blobs, in a context somebody else owns.
884
+ *
885
+ * `null`, NOT A THROW, for every construction failure — the idiom `createCanvasStage` already sets
886
+ * in this repo, and the difference between a measurement arm and a shipped renderer. A consumer
887
+ * that cannot have this one falls back to its DOM text path; a consumer that WANTS to be loud
888
+ * passes `onError` and is told the reason, because a silently skipped renderer reports as a cheap
889
+ * one.
890
+ *
891
+ * DESIGN SPACE IS DEVICE PIXELS, and not by preference: `u_viewport` is the framebuffer size and
892
+ * `hb_gpu_dilate` uses it with the projection to work out how far half a screen pixel is in object
893
+ * units. Any scale in the projection would make the dilation and the quad disagree, and a dilation
894
+ * that is wrong by a fraction is a rim of clipped antialiasing around every glyph.
895
+ */
896
+ let rendererIdentity = 0;
897
+ /** Non-enumerable provenance survives a direct slot hand-off but deliberately not `{ ...slot }`. */
898
+ const SLOT_OWNER = Symbol("hb-gpu-slot-owner");
899
+ function createHbGpuRenderer(module, options) {
900
+ const rid = ++rendererIdentity;
901
+ const gl = options.gl;
902
+ const report = (reason, message) => {
903
+ options.onError?.({
904
+ reason,
905
+ message: `hb-gpu: ${message}`
906
+ });
907
+ };
908
+ const refuse = (reason, message) => {
909
+ report(reason, message);
910
+ return null;
911
+ };
912
+ if (gl.isContextLost()) return refuse("context-lost", "the context handed to createHbGpuRenderer is already lost — every object built now would be dead on arrival");
913
+ const maxTextureSize = Number(gl.getParameter(gl.MAX_TEXTURE_SIZE)) || 0;
914
+ if (maxTextureSize < MIN_ATLAS_WIDTH) return refuse("texture-size", `MAX_TEXTURE_SIZE is ${maxTextureSize}, below the ${MIN_ATLAS_WIDTH} this renderer needs (WebGL2 itself guarantees 2048) — the atlas cannot be built`);
915
+ const atlasWidth = Math.min(ATLAS_WIDTH, maxTextureSize);
916
+ if (atlasWidth !== 4096) report("atlas-clamped", `MAX_TEXTURE_SIZE is ${maxTextureSize}, so the atlas is ${atlasWidth} texels wide instead of ${ATLAS_WIDTH} — correct (the width is a uniform) but each blob now spans more rows and costs more texSubImage2D calls`);
917
+ const spreadTapMsaa = options.spreadTapMsaa ?? false;
918
+ const contrast = options.contrast ?? HB_GPU_CONTRAST_DEFAULT;
919
+ const perInstanceRunState = options.perInstanceRunState === true;
920
+ const instanceFloatsPerRecord = perInstanceRunState ? BATCHED_INSTANCE_FLOATS : LEGACY_INSTANCE_FLOATS;
921
+ const instanceBytes = instanceFloatsPerRecord * 4;
922
+ let contrastGamma = contrast.gamma;
923
+ if (!Number.isFinite(contrastGamma) || contrastGamma <= 0) {
924
+ report("degenerate-contrast", `contrast.gamma is ${String(contrast.gamma)}, which \`pow\` cannot take as an exponent — using 1 (no gamma). Stem darkening is unaffected and is ${contrast.stemDarkening ? "on" : "off"}.`);
925
+ contrastGamma = 1;
926
+ }
927
+ const contrastStemDarken = contrast.stemDarkening ? 1 : 0;
928
+ const requestedTexels = Math.max(1, options.atlasTexels ?? atlasWidth * 256);
929
+ const requestedRows = Math.max(1, Math.ceil(requestedTexels / atlasWidth));
930
+ const atlasHeight = Math.min(requestedRows, maxTextureSize);
931
+ if (atlasHeight !== requestedRows) report("atlas-clamped", `${requestedTexels} texels needs ${requestedRows} rows but MAX_TEXTURE_SIZE caps the texture at ${maxTextureSize} — the atlas holds ${atlasHeight * atlasWidth} texels, and a working set larger than that will thrash the ring`);
932
+ const capacityTexels = atlasHeight * atlasWidth;
933
+ let program = null;
934
+ let uViewProjection = null;
935
+ let uMatViewProjection = null;
936
+ let uColor = null;
937
+ let uSpreadPx = null;
938
+ let uViewport = null;
939
+ let uGamma = null;
940
+ let uStemDarken = null;
941
+ let uAtlas = null;
942
+ let uAtlasWidth = null;
943
+ let aNormal = -1;
944
+ let aPosition = -1;
945
+ let aTexcoord = -1;
946
+ let aEmPerPos = -1;
947
+ let aGlyphLoc = -1;
948
+ let aModel0 = -1;
949
+ let aModel1 = -1;
950
+ let aModel2 = -1;
951
+ let aColor = -1;
952
+ let aSpreadPx = -1;
953
+ let atlasTexture = null;
954
+ let vao = null;
955
+ let cornerBuffer = null;
956
+ let instanceBuffer = null;
957
+ let instanceCapacity = 256;
958
+ let instanceData = new ArrayBuffer(instanceCapacity * instanceBytes);
959
+ let instanceFloats = new Float32Array(instanceData);
960
+ let instanceUints = new Uint32Array(instanceData);
961
+ function buildProgram() {
962
+ const vertex = compileShader(gl, gl.VERTEX_SHADER, GLSL_PREAMBLE + module.shaderLibrary(0) + (perInstanceRunState ? BATCHED_VERTEX_MAIN : VERTEX_MAIN));
963
+ if ("message" in vertex) return vertex;
964
+ const fragment = compileShader(gl, gl.FRAGMENT_SHADER, GLSL_PREAMBLE + (spreadTapMsaa ? "" : SPREAD_TAP_NO_MSAA_DEFINE) + module.shaderLibrary(1) + (perInstanceRunState ? BATCHED_FRAGMENT_MAIN : FRAGMENT_MAIN));
965
+ if ("message" in fragment) {
966
+ gl.deleteShader(vertex.shader);
967
+ return fragment;
968
+ }
969
+ const created = gl.createProgram();
970
+ if (!created) {
971
+ gl.deleteShader(vertex.shader);
972
+ gl.deleteShader(fragment.shader);
973
+ return {
974
+ reason: "gl-object",
975
+ message: "gl.createProgram returned null (context lost?)"
976
+ };
977
+ }
978
+ gl.attachShader(created, vertex.shader);
979
+ gl.attachShader(created, fragment.shader);
980
+ gl.linkProgram(created);
981
+ gl.deleteShader(vertex.shader);
982
+ gl.deleteShader(fragment.shader);
983
+ if (!gl.getProgramParameter(created, gl.LINK_STATUS)) {
984
+ const log = gl.getProgramInfoLog(created) || "(no log)";
985
+ gl.deleteProgram(created);
986
+ return {
987
+ reason: "program-link",
988
+ message: `program failed to link — ${log}`
989
+ };
990
+ }
991
+ program = created;
992
+ if (perInstanceRunState) uViewProjection = gl.getUniformLocation(created, "u_viewProjection");
993
+ else uMatViewProjection = gl.getUniformLocation(created, "u_matViewProjection");
994
+ uViewport = gl.getUniformLocation(created, "u_viewport");
995
+ if (!perInstanceRunState) {
996
+ uColor = gl.getUniformLocation(created, "u_color");
997
+ uSpreadPx = gl.getUniformLocation(created, "u_spreadPx");
998
+ }
999
+ uGamma = gl.getUniformLocation(created, "u_gamma");
1000
+ uStemDarken = gl.getUniformLocation(created, "u_stemDarken");
1001
+ uAtlas = gl.getUniformLocation(created, "hb_gpu_atlas");
1002
+ uAtlasWidth = gl.getUniformLocation(created, "hb_gpu_atlas_width");
1003
+ aNormal = gl.getAttribLocation(created, "a_normal");
1004
+ aPosition = gl.getAttribLocation(created, "a_position");
1005
+ aTexcoord = gl.getAttribLocation(created, "a_texcoord");
1006
+ aEmPerPos = gl.getAttribLocation(created, "a_emPerPos");
1007
+ aGlyphLoc = gl.getAttribLocation(created, "a_glyphLoc");
1008
+ if (perInstanceRunState) {
1009
+ aModel0 = gl.getAttribLocation(created, "a_model0");
1010
+ aModel1 = gl.getAttribLocation(created, "a_model1");
1011
+ aModel2 = gl.getAttribLocation(created, "a_model2");
1012
+ aColor = gl.getAttribLocation(created, "a_color");
1013
+ aSpreadPx = gl.getAttribLocation(created, "a_spreadPx");
1014
+ }
1015
+ const missing = [
1016
+ ["a_normal", aNormal],
1017
+ ["a_position", aPosition],
1018
+ ["a_texcoord", aTexcoord],
1019
+ ["a_emPerPos", aEmPerPos],
1020
+ ["a_glyphLoc", aGlyphLoc],
1021
+ ...perInstanceRunState ? [
1022
+ ["a_model0", aModel0],
1023
+ ["a_model1", aModel1],
1024
+ ["a_model2", aModel2],
1025
+ ["a_color", aColor],
1026
+ ["a_spreadPx", aSpreadPx]
1027
+ ] : []
1028
+ ].filter(([, location]) => location < 0).map(([name]) => name);
1029
+ if (missing.length > 0) return {
1030
+ reason: "program-link",
1031
+ message: `the linked program has no location for ${missing.join(", ")} — the shader library does not match this file's main()`
1032
+ };
1033
+ gl.useProgram(created);
1034
+ gl.uniform1i(uAtlas, 0);
1035
+ gl.uniform1i(uAtlasWidth, atlasWidth);
1036
+ gl.uniform1f(uGamma, contrastGamma);
1037
+ gl.uniform1f(uStemDarken, contrastStemDarken);
1038
+ return null;
1039
+ }
1040
+ function buildAtlasTexture() {
1041
+ const created = gl.createTexture();
1042
+ if (!created) return {
1043
+ reason: "gl-object",
1044
+ message: "gl.createTexture returned null (context lost?)"
1045
+ };
1046
+ atlasTexture = created;
1047
+ gl.activeTexture(gl.TEXTURE0);
1048
+ gl.bindTexture(gl.TEXTURE_2D, created);
1049
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16I, atlasWidth, atlasHeight, 0, gl.RGBA_INTEGER, gl.SHORT, null);
1050
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
1051
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
1052
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1053
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1054
+ return null;
1055
+ }
1056
+ function bindInstanceAttributes() {
1057
+ gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer);
1058
+ gl.enableVertexAttribArray(aPosition);
1059
+ gl.vertexAttribPointer(aPosition, 4, gl.FLOAT, false, instanceBytes, 0);
1060
+ gl.vertexAttribDivisor(aPosition, 1);
1061
+ gl.enableVertexAttribArray(aTexcoord);
1062
+ gl.vertexAttribPointer(aTexcoord, 4, gl.FLOAT, false, instanceBytes, 16);
1063
+ gl.vertexAttribDivisor(aTexcoord, 1);
1064
+ gl.enableVertexAttribArray(aEmPerPos);
1065
+ gl.vertexAttribPointer(aEmPerPos, 1, gl.FLOAT, false, instanceBytes, 32);
1066
+ gl.vertexAttribDivisor(aEmPerPos, 1);
1067
+ gl.enableVertexAttribArray(aGlyphLoc);
1068
+ gl.vertexAttribIPointer(aGlyphLoc, 1, gl.UNSIGNED_INT, instanceBytes, 36);
1069
+ gl.vertexAttribDivisor(aGlyphLoc, 1);
1070
+ if (!perInstanceRunState) return;
1071
+ gl.enableVertexAttribArray(aModel0);
1072
+ gl.vertexAttribPointer(aModel0, 2, gl.FLOAT, false, instanceBytes, 40);
1073
+ gl.vertexAttribDivisor(aModel0, 1);
1074
+ gl.enableVertexAttribArray(aModel1);
1075
+ gl.vertexAttribPointer(aModel1, 2, gl.FLOAT, false, instanceBytes, 48);
1076
+ gl.vertexAttribDivisor(aModel1, 1);
1077
+ gl.enableVertexAttribArray(aModel2);
1078
+ gl.vertexAttribPointer(aModel2, 2, gl.FLOAT, false, instanceBytes, 56);
1079
+ gl.vertexAttribDivisor(aModel2, 1);
1080
+ gl.enableVertexAttribArray(aColor);
1081
+ gl.vertexAttribPointer(aColor, 4, gl.FLOAT, false, instanceBytes, 64);
1082
+ gl.vertexAttribDivisor(aColor, 1);
1083
+ gl.enableVertexAttribArray(aSpreadPx);
1084
+ gl.vertexAttribPointer(aSpreadPx, 1, gl.FLOAT, false, instanceBytes, 80);
1085
+ gl.vertexAttribDivisor(aSpreadPx, 1);
1086
+ }
1087
+ function buildGeometry() {
1088
+ const createdVao = gl.createVertexArray();
1089
+ const createdCorner = gl.createBuffer();
1090
+ const createdInstance = gl.createBuffer();
1091
+ if (!createdVao || !createdCorner || !createdInstance) return {
1092
+ reason: "gl-object",
1093
+ message: "gl.createVertexArray/createBuffer returned null (context lost?)"
1094
+ };
1095
+ vao = createdVao;
1096
+ cornerBuffer = createdCorner;
1097
+ instanceBuffer = createdInstance;
1098
+ gl.bindVertexArray(createdVao);
1099
+ gl.bindBuffer(gl.ARRAY_BUFFER, createdCorner);
1100
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
1101
+ -1,
1102
+ 1,
1103
+ -1,
1104
+ -1,
1105
+ 1,
1106
+ 1,
1107
+ 1,
1108
+ -1
1109
+ ]), gl.STATIC_DRAW);
1110
+ gl.enableVertexAttribArray(aNormal);
1111
+ gl.vertexAttribPointer(aNormal, 2, gl.FLOAT, false, 0, 0);
1112
+ gl.bindBuffer(gl.ARRAY_BUFFER, createdInstance);
1113
+ gl.bufferData(gl.ARRAY_BUFFER, instanceData.byteLength, gl.DYNAMIC_DRAW);
1114
+ bindInstanceAttributes();
1115
+ gl.bindVertexArray(null);
1116
+ return null;
1117
+ }
1118
+ function buildGlObjects() {
1119
+ return buildProgram() ?? buildAtlasTexture() ?? buildGeometry();
1120
+ }
1121
+ /** Forget every handle WITHOUT calling GL. What a lost context leaves behind. */
1122
+ function dropGlObjects() {
1123
+ program = null;
1124
+ atlasTexture = null;
1125
+ vao = null;
1126
+ cornerBuffer = null;
1127
+ instanceBuffer = null;
1128
+ }
1129
+ function deleteGlObjects() {
1130
+ if (vao) gl.deleteVertexArray(vao);
1131
+ if (cornerBuffer) gl.deleteBuffer(cornerBuffer);
1132
+ if (instanceBuffer) gl.deleteBuffer(instanceBuffer);
1133
+ if (atlasTexture) gl.deleteTexture(atlasTexture);
1134
+ if (program) gl.deleteProgram(program);
1135
+ dropGlObjects();
1136
+ }
1137
+ const initialFailure = buildGlObjects();
1138
+ if (initialFailure) {
1139
+ deleteGlObjects();
1140
+ return refuse(initialFailure.reason, initialFailure.message);
1141
+ }
1142
+ const faces = [];
1143
+ /** Compatibility/debug lookup only; hot resolve/push use the numeric face maps below. */
1144
+ const allocations = /* @__PURE__ */ new Map();
1145
+ const byFace = [];
1146
+ /** Identity proof for slots this renderer minted; structural legacy slots have no entry here. */
1147
+ const ownedSlots = /* @__PURE__ */ new WeakMap();
1148
+ /** Live allocations, always sorted by atlas offset. */
1149
+ const order = [];
1150
+ let cursor = 0;
1151
+ let touchCounter = 0;
1152
+ /** Monotonic touch stamp. One counter, one place it advances. */
1153
+ const touch = () => {
1154
+ touchCounter += 1;
1155
+ return touchCounter;
1156
+ };
1157
+ /** Monotonic allocation stamp. Never reused, never reset — see {@link GlyphSlot.generation}. */
1158
+ let generationCounter = 0;
1159
+ let evictions = 0;
1160
+ let staleSkips = 0;
1161
+ let liveTexels = 0;
1162
+ let frameIndex = 0;
1163
+ let blobGlyphs = 0;
1164
+ let blobBytes = 0;
1165
+ let contextLost = false;
1166
+ let designWidth = Math.max(1, options.designWidth);
1167
+ let designHeight = Math.max(1, options.designHeight);
1168
+ let framebufferWidth = Math.max(1, options.framebufferWidth ?? options.designWidth);
1169
+ let framebufferHeight = Math.max(1, options.framebufferHeight ?? options.designHeight);
1170
+ /**
1171
+ * Place `texels` texels and return the offset, evicting whatever the cursor lands on.
1172
+ *
1173
+ * A BUMP RING, NOT `die ("Ran out of atlas memory")`. Allocations are laid down in touch order,
1174
+ * so sweeping the cursor forward overwrites the OLDEST region first — which is LRU for the
1175
+ * workload this exists for, a glyph pool larger than the atlas where every resident glyph is
1176
+ * touched at most once a frame. It is only an approximation once a key is re-uploaded, and the
1177
+ * approximation is not what makes this safe.
1178
+ *
1179
+ * WHAT MAKES IT SAFE IS THE IN-USE GUARD, AND THAT GUARD STAYS A THROW. Everything else in this
1180
+ * file degrades to `null` plus an `onError`, because a shipped renderer must let its consumer
1181
+ * fall back. This one does not, and the judgement is deliberate:
1182
+ *
1183
+ * - It is not a runtime condition. It says the atlas cannot hold ONE FRAME's working set, which
1184
+ * is a sizing decision the embedder made before any frame ran. `atlas.capacityTexels` and
1185
+ * `atlas.liveTexels` are published precisely so it can be made correctly.
1186
+ * - Neither repair is honest. Evicting the victim draws a DIFFERENT glyph's outline in its
1187
+ * place, at the right size, in the right position, perfectly antialiased — unreadable text
1188
+ * that looks like working text. Declining the new glyph instead leaves the frame short, every
1189
+ * frame, forever, reported only as a counter nobody reads.
1190
+ * - It is not the hot loop. `push` never throws; this is the cache-miss path.
1191
+ */
1192
+ function removeAllocation(entry) {
1193
+ if (!entry.live) return;
1194
+ entry.live = false;
1195
+ allocations.delete(entry.key);
1196
+ byFace[entry.faceId]?.delete(entry.glyphId);
1197
+ const index = order.indexOf(entry);
1198
+ if (index >= 0) order.splice(index, 1);
1199
+ liveTexels -= entry.texels;
1200
+ }
1201
+ function allocate(key, texels) {
1202
+ if (cursor + texels > capacityTexels) cursor = 0;
1203
+ const start = cursor;
1204
+ const end = cursor + texels;
1205
+ let first = 0;
1206
+ let last = order.length;
1207
+ while (first < last) {
1208
+ const middle = first + last >>> 1;
1209
+ if (order[middle].offset + order[middle].texels <= start) first = middle + 1;
1210
+ else last = middle;
1211
+ }
1212
+ let after = first;
1213
+ while (after < order.length && order[after].offset < end) after += 1;
1214
+ for (let i = first; i < after; i += 1) {
1215
+ const victim = order[i];
1216
+ if (victim.usedFrame === frameIndex) throw new Error(`hb-gpu: the atlas (${capacityTexels} texels) cannot hold one frame's glyphs — placing "${key}" would overwrite "${victim.key}", already drawn this frame`);
1217
+ }
1218
+ for (let i = first; i < after; i += 1) {
1219
+ const victim = order[i];
1220
+ victim.live = false;
1221
+ allocations.delete(victim.key);
1222
+ byFace[victim.faceId]?.delete(victim.glyphId);
1223
+ liveTexels -= victim.texels;
1224
+ evictions += 1;
1225
+ }
1226
+ if (after > first) order.splice(first, after - first);
1227
+ cursor = end;
1228
+ return start;
1229
+ }
1230
+ /**
1231
+ * Upload one blob's texels, row by row.
1232
+ *
1233
+ * ROW BY ROW BECAUSE THE STREAM IS 1-D AND THE TEXTURE IS NOT. A blob is a run of texels at some
1234
+ * absolute offset; that run generally starts mid-row and spans several. `texSubImage2D` can only
1235
+ * write rectangles, so each row-fragment is its own call — which is upstream's loop, and the
1236
+ * single most delicate arithmetic in this file. Off by one row and the glyph's band headers read
1237
+ * as curve data.
1238
+ */
1239
+ function uploadTexels(offset, texels) {
1240
+ const shorts = texels.byteOffset % 2 === 0 ? new Int16Array(texels.buffer, texels.byteOffset, texels.byteLength / 2) : new Int16Array(texels.slice().buffer);
1241
+ gl.activeTexture(gl.TEXTURE0);
1242
+ gl.bindTexture(gl.TEXTURE_2D, atlasTexture);
1243
+ const previousAlignment = Number(gl.getParameter(gl.UNPACK_ALIGNMENT)) || 4;
1244
+ if (previousAlignment !== 4) gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4);
1245
+ const previousFlipY = Boolean(gl.getParameter(gl.UNPACK_FLIP_Y_WEBGL));
1246
+ const previousPremultiply = Boolean(gl.getParameter(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL));
1247
+ if (previousFlipY) gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
1248
+ if (previousPremultiply) gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
1249
+ let remaining = texels.byteLength / 8;
1250
+ let source = 0;
1251
+ let destination = offset;
1252
+ while (remaining > 0) {
1253
+ const x = destination % atlasWidth;
1254
+ const y = Math.floor(destination / atlasWidth);
1255
+ const run = Math.min(atlasWidth - x, remaining);
1256
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, x, y, run, 1, gl.RGBA_INTEGER, gl.SHORT, shorts, source * 4);
1257
+ source += run;
1258
+ destination += run;
1259
+ remaining -= run;
1260
+ }
1261
+ if (previousAlignment !== 4) gl.pixelStorei(gl.UNPACK_ALIGNMENT, previousAlignment);
1262
+ if (previousFlipY) gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
1263
+ if (previousPremultiply) gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
1264
+ }
1265
+ let instanceCount = 0;
1266
+ const model = new Float32Array([
1267
+ 1,
1268
+ 0,
1269
+ 0,
1270
+ 1,
1271
+ 0,
1272
+ 0
1273
+ ]);
1274
+ const color = new Float32Array([
1275
+ 1,
1276
+ 1,
1277
+ 1,
1278
+ 1
1279
+ ]);
1280
+ /** Object units, and 0 is the fill path. See {@link HbGpuRenderer.setSpread}. */
1281
+ let spreadPx = 0;
1282
+ const mvp = new Float32Array(16);
1283
+ let mvpDirty = true;
1284
+ let colorDirty = true;
1285
+ let spreadDirty = true;
1286
+ let viewportDirty = true;
1287
+ /**
1288
+ * Design -> clip as `(scaleX, scaleY, translateX, translateY)`, byte-identical to
1289
+ * `createCanvasStage`'s `toClip`.
1290
+ *
1291
+ * `scaleY` is NEGATIVE because design space measures y DOWNWARDS and clip space upwards. Computed
1292
+ * here rather than read off a stage because this renderer no longer has one — and restated in the
1293
+ * same four numbers, in the same order, so the two cannot silently diverge.
1294
+ *
1295
+ * THE DESIGN PAIR, NOT THE FRAMEBUFFER PAIR. The framebuffer pair is `u_viewport` and only that.
1296
+ */
1297
+ const toClip = new Float32Array(4);
1298
+ function refreshProjection() {
1299
+ toClip[0] = 2 / designWidth;
1300
+ toClip[1] = -2 / designHeight;
1301
+ toClip[2] = -1;
1302
+ toClip[3] = 1;
1303
+ }
1304
+ refreshProjection();
1305
+ /**
1306
+ * Design-to-clip as the column-major `mat4` GLSL wants. The batched program receives only this
1307
+ * projection; the established renderer keeps its complete model-view-projection uniform.
1308
+ *
1309
+ * ONE matrix, and it has to be this one. `hb_gpu_dilate` is handed the same `m` and works out
1310
+ * how far half a screen pixel is by pushing the vertex AND its normal through it, so any part of
1311
+ * the transform applied elsewhere — a quad rotated on the CPU, a viewport scale — is a transform
1312
+ * the dilation cannot see.
1313
+ */
1314
+ function refreshMatrix() {
1315
+ const sx = toClip[0];
1316
+ const sy = toClip[1];
1317
+ mvp.fill(0);
1318
+ mvp[0] = perInstanceRunState ? sx : sx * model[0];
1319
+ mvp[1] = perInstanceRunState ? 0 : sy * model[1];
1320
+ mvp[4] = perInstanceRunState ? 0 : sx * model[2];
1321
+ mvp[5] = perInstanceRunState ? sy : sy * model[3];
1322
+ mvp[10] = 1;
1323
+ mvp[12] = perInstanceRunState ? toClip[2] : sx * model[4] + toClip[2];
1324
+ mvp[13] = perInstanceRunState ? toClip[3] : sy * model[5] + toClip[3];
1325
+ mvp[15] = 1;
1326
+ }
1327
+ function growInstances() {
1328
+ instanceCapacity *= 2;
1329
+ const next = new ArrayBuffer(instanceCapacity * instanceBytes);
1330
+ new Uint8Array(next).set(new Uint8Array(instanceData));
1331
+ instanceData = next;
1332
+ instanceFloats = new Float32Array(instanceData);
1333
+ instanceUints = new Uint32Array(instanceData);
1334
+ gl.bindVertexArray(vao);
1335
+ gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer);
1336
+ gl.bufferData(gl.ARRAY_BUFFER, instanceData.byteLength, gl.DYNAMIC_DRAW);
1337
+ bindInstanceAttributes();
1338
+ gl.bindVertexArray(null);
1339
+ }
1340
+ /**
1341
+ * The slot an allocation describes. ONE expression, and that is the point of it.
1342
+ *
1343
+ * A fresh slot, a slot for a glyph that was already resident and a slot handed back by
1344
+ * {@link HbGpuRenderer.resolve} all come from here, so they cannot disagree about the ink box,
1345
+ * the generation or the offset. It reads the ALLOCATION only — it takes no `EncodedGlyph` —
1346
+ * which is what lets `resolve` exist without an encoder.
1347
+ */
1348
+ function slotFor(entry) {
1349
+ return entry.slot;
1350
+ }
1351
+ return {
1352
+ gl,
1353
+ atlasWidth,
1354
+ get contextLost() {
1355
+ return contextLost;
1356
+ },
1357
+ registerFace(font, label) {
1358
+ const upem = font.upem;
1359
+ if (!Number.isInteger(upem) || upem <= 0) return refuse("degenerate-upem", `face "${label ?? faces.length}" reports upem ${upem} — every glyph scaled by it would be NaN and draw nothing`);
1360
+ const face = {
1361
+ id: faces.length,
1362
+ label: label ?? `face${faces.length}`,
1363
+ upem,
1364
+ font
1365
+ };
1366
+ faces.push(face);
1367
+ byFace.push(/* @__PURE__ */ new Map());
1368
+ return face;
1369
+ },
1370
+ upload(face, glyphId, glyph) {
1371
+ if (contextLost) {
1372
+ report("context-lost", `upload of glyph ${glyphId} ignored while the context is lost — call rebuild() from webglcontextrestored`);
1373
+ return null;
1374
+ }
1375
+ const registered = faces[face.id];
1376
+ if (!registered || registered !== face) return refuse("face-unregistered", `face "${face.label}" (id ${face.id}) was not registered with this renderer — its keys would collide with whatever face holds that id`);
1377
+ const key = `${face.id}/${glyphId}`;
1378
+ const existing = byFace[face.id]?.get(glyphId);
1379
+ if (existing) {
1380
+ existing.usedAt = touch();
1381
+ return slotFor(existing);
1382
+ }
1383
+ if (glyph.texels.length === 0) return null;
1384
+ if (glyph.texels.length % 8 !== 0) return refuse("blob-malformed", `blob for "${key}" is ${glyph.texels.length} bytes, not a whole number of 8-byte texels — it would be uploaded a texel short and read as curve data`);
1385
+ const texels = glyph.texels.length / 8;
1386
+ if (texels > capacityTexels) return refuse("blob-too-large", `glyph "${key}" needs ${texels} texels but the whole atlas is ${capacityTexels} — raise atlasTexels; this glyph will not be drawn`);
1387
+ const offset = allocate(key, texels);
1388
+ uploadTexels(offset, glyph.texels);
1389
+ generationCounter += 1;
1390
+ const entry = {
1391
+ key,
1392
+ faceId: face.id,
1393
+ glyphId,
1394
+ offset,
1395
+ texels,
1396
+ generation: generationCounter,
1397
+ live: true,
1398
+ rid,
1399
+ usedAt: touch(),
1400
+ usedFrame: -1,
1401
+ upem: face.upem,
1402
+ minX: glyph.extents.xBearing,
1403
+ minY: glyph.extents.yBearing + glyph.extents.height,
1404
+ maxX: glyph.extents.xBearing + glyph.extents.width,
1405
+ maxY: glyph.extents.yBearing
1406
+ };
1407
+ const slot = {
1408
+ faceId: face.id,
1409
+ glyphId,
1410
+ key,
1411
+ generation: entry.generation,
1412
+ loc: entry.offset,
1413
+ upem: entry.upem,
1414
+ minX: entry.minX,
1415
+ minY: entry.minY,
1416
+ maxX: entry.maxX,
1417
+ maxY: entry.maxY,
1418
+ texels: entry.texels
1419
+ };
1420
+ Object.defineProperty(slot, SLOT_OWNER, { value: rid });
1421
+ const owned = {
1422
+ ...entry,
1423
+ slot
1424
+ };
1425
+ ownedSlots.set(slot, owned);
1426
+ allocations.set(key, owned);
1427
+ byFace[face.id].set(glyphId, owned);
1428
+ let insertion = 0;
1429
+ while (insertion < order.length && order[insertion].offset < offset) insertion += 1;
1430
+ order.splice(insertion, 0, owned);
1431
+ liveTexels += texels;
1432
+ blobGlyphs += 1;
1433
+ blobBytes += glyph.texels.length;
1434
+ return slotFor(owned);
1435
+ },
1436
+ resolve(face, glyphId) {
1437
+ if (contextLost) return null;
1438
+ const registered = faces[face.id];
1439
+ if (!registered || registered !== face) return refuse("face-unregistered", `face "${face.label}" (id ${face.id}) was not registered with this renderer — resolve() cannot answer for it, and every glyph of the run would be missing`);
1440
+ const entry = byFace[face.id]?.get(glyphId);
1441
+ if (!entry) return null;
1442
+ entry.usedAt = touch();
1443
+ return slotFor(entry);
1444
+ },
1445
+ begin() {
1446
+ frameIndex += 1;
1447
+ instanceCount = 0;
1448
+ },
1449
+ push(slot, x, y, pixelsPerEm) {
1450
+ if (contextLost) return;
1451
+ const owned = ownedSlots.get(slot);
1452
+ const owner = slot[SLOT_OWNER];
1453
+ const entry = owned ? owned : owner !== void 0 && owner !== rid ? void 0 : slot.faceId !== void 0 && slot.glyphId !== void 0 ? byFace[slot.faceId]?.get(slot.glyphId) : allocations.get(slot.key);
1454
+ if (!entry || entry.rid !== rid || !entry.live || entry.generation !== slot.generation || entry.offset !== slot.loc) {
1455
+ staleSkips += 1;
1456
+ return;
1457
+ }
1458
+ if (instanceCount >= instanceCapacity) growInstances();
1459
+ entry.usedFrame = frameIndex;
1460
+ entry.usedAt = touch();
1461
+ const scale = pixelsPerEm / slot.upem;
1462
+ const base = instanceCount * instanceFloatsPerRecord;
1463
+ instanceFloats[base] = x + scale * slot.minX;
1464
+ instanceFloats[base + 1] = y - scale * slot.minY;
1465
+ instanceFloats[base + 2] = x + scale * slot.maxX;
1466
+ instanceFloats[base + 3] = y - scale * slot.maxY;
1467
+ instanceFloats[base + 4] = slot.minX;
1468
+ instanceFloats[base + 5] = slot.minY;
1469
+ instanceFloats[base + 6] = slot.maxX;
1470
+ instanceFloats[base + 7] = slot.maxY;
1471
+ instanceFloats[base + 8] = 1 / scale;
1472
+ instanceUints[base + 9] = slot.loc >>> 0;
1473
+ if (perInstanceRunState) {
1474
+ for (let i = 0; i < 6; i += 1) instanceFloats[base + 10 + i] = model[i];
1475
+ instanceFloats[base + 16] = color[0];
1476
+ instanceFloats[base + 17] = color[1];
1477
+ instanceFloats[base + 18] = color[2];
1478
+ instanceFloats[base + 19] = color[3];
1479
+ instanceFloats[base + 20] = spreadPx;
1480
+ }
1481
+ instanceCount += 1;
1482
+ },
1483
+ setModel(next) {
1484
+ for (let i = 0; i < 6; i += 1) {
1485
+ if (!perInstanceRunState && model[i] !== next[i]) mvpDirty = true;
1486
+ model[i] = next[i];
1487
+ }
1488
+ },
1489
+ setColor(r, g, b, a) {
1490
+ if (!perInstanceRunState) colorDirty ||= color[0] !== r || color[1] !== g || color[2] !== b || color[3] !== a;
1491
+ color[0] = r;
1492
+ color[1] = g;
1493
+ color[2] = b;
1494
+ color[3] = a;
1495
+ },
1496
+ setSpread(px) {
1497
+ const next = Number.isFinite(px) && px > 0 ? px : 0;
1498
+ if (!perInstanceRunState && spreadPx !== next) spreadDirty = true;
1499
+ spreadPx = next;
1500
+ },
1501
+ setViewport(width, height, bufferWidth, bufferHeight) {
1502
+ const nextDesignWidth = Math.max(1, width);
1503
+ const nextDesignHeight = Math.max(1, height);
1504
+ const nextFramebufferWidth = Math.max(1, bufferWidth ?? width);
1505
+ const nextFramebufferHeight = Math.max(1, bufferHeight ?? height);
1506
+ if (designWidth !== nextDesignWidth || designHeight !== nextDesignHeight) {
1507
+ designWidth = nextDesignWidth;
1508
+ designHeight = nextDesignHeight;
1509
+ refreshProjection();
1510
+ mvpDirty = true;
1511
+ }
1512
+ if (framebufferWidth !== nextFramebufferWidth || framebufferHeight !== nextFramebufferHeight) {
1513
+ framebufferWidth = nextFramebufferWidth;
1514
+ framebufferHeight = nextFramebufferHeight;
1515
+ viewportDirty = true;
1516
+ }
1517
+ },
1518
+ end() {
1519
+ if (contextLost || instanceCount === 0) return {
1520
+ instances: 0,
1521
+ drawCalls: 0
1522
+ };
1523
+ if (!program || !vao || !instanceBuffer || !atlasTexture) return {
1524
+ instances: 0,
1525
+ drawCalls: 0
1526
+ };
1527
+ gl.useProgram(program);
1528
+ gl.bindVertexArray(vao);
1529
+ gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer);
1530
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, instanceFloats, 0, instanceCount * instanceFloatsPerRecord);
1531
+ gl.activeTexture(gl.TEXTURE0);
1532
+ gl.bindTexture(gl.TEXTURE_2D, atlasTexture);
1533
+ if (mvpDirty) {
1534
+ refreshMatrix();
1535
+ gl.uniformMatrix4fv(perInstanceRunState ? uViewProjection : uMatViewProjection, false, mvp);
1536
+ mvpDirty = false;
1537
+ }
1538
+ if (!perInstanceRunState && colorDirty) {
1539
+ gl.uniform4fv(uColor, color);
1540
+ colorDirty = false;
1541
+ }
1542
+ if (!perInstanceRunState && spreadDirty) {
1543
+ gl.uniform1f(uSpreadPx, spreadPx);
1544
+ spreadDirty = false;
1545
+ }
1546
+ if (viewportDirty) {
1547
+ gl.uniform2f(uViewport, framebufferWidth, framebufferHeight);
1548
+ viewportDirty = false;
1549
+ }
1550
+ gl.enable(gl.BLEND);
1551
+ gl.blendEquation(gl.FUNC_ADD);
1552
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
1553
+ gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, instanceCount);
1554
+ gl.bindVertexArray(null);
1555
+ return {
1556
+ instances: instanceCount,
1557
+ drawCalls: 1
1558
+ };
1559
+ },
1560
+ notifyContextLost() {
1561
+ contextLost = true;
1562
+ instanceCount = 0;
1563
+ dropGlObjects();
1564
+ },
1565
+ rebuild() {
1566
+ const failure = buildGlObjects();
1567
+ if (failure) {
1568
+ deleteGlObjects();
1569
+ report(failure.reason, `rebuild after context loss failed — ${failure.message}`);
1570
+ return false;
1571
+ }
1572
+ mvpDirty = true;
1573
+ colorDirty = true;
1574
+ spreadDirty = true;
1575
+ viewportDirty = true;
1576
+ const resident = [...order];
1577
+ const dropped = [];
1578
+ for (const entry of resident) {
1579
+ const face = faces[entry.faceId];
1580
+ const glyph = face ? face.font.encode(entry.glyphId) : null;
1581
+ if (!glyph || glyph.texels.length !== entry.texels * 8) {
1582
+ removeAllocation(entry);
1583
+ dropped.push(entry.key);
1584
+ continue;
1585
+ }
1586
+ uploadTexels(entry.offset, glyph.texels);
1587
+ }
1588
+ contextLost = false;
1589
+ if (dropped.length > 0) report("rebuild-incomplete", `${dropped.length} of ${resident.length} resident glyphs could not be re-encoded after a context loss (${dropped.slice(0, 8).join(", ")}${dropped.length > 8 ? ", …" : ""}) — their faces' fonts must outlive the renderer; those glyphs will be skipped until re-uploaded`);
1590
+ return true;
1591
+ },
1592
+ get atlas() {
1593
+ return {
1594
+ liveBytes: liveTexels * 8,
1595
+ reservationBytes: atlasWidth * atlasHeight * 8,
1596
+ entries: allocations.size,
1597
+ liveTexels,
1598
+ capacityTexels,
1599
+ evictions,
1600
+ faces: faces.length,
1601
+ staleSkips
1602
+ };
1603
+ },
1604
+ get blobs() {
1605
+ return {
1606
+ glyphs: blobGlyphs,
1607
+ totalBytes: blobBytes,
1608
+ bytesPerGlyph: blobGlyphs > 0 ? blobBytes / blobGlyphs : 0
1609
+ };
1610
+ },
1611
+ dispose() {
1612
+ if (!contextLost) deleteGlObjects();
1613
+ else dropGlObjects();
1614
+ faces.length = 0;
1615
+ allocations.clear();
1616
+ byFace.length = 0;
1617
+ order.length = 0;
1618
+ }
1619
+ };
1620
+ }
1621
+ //#endregion
1622
+ export { ATLAS_WIDTH, FRAGMENT_MAIN, HB_GPU_CONTRAST_DEFAULT, HB_GPU_CONTRAST_NONE, HB_GPU_SPREAD_MAX_TAPS, createHbGpuRenderer };
1623
+
1624
+ //# sourceMappingURL=webgl.js.map