@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.
@@ -0,0 +1,523 @@
1
+ import { EncodedGlyph, HbGpu, HbGpuFailure, HbGpuFont } from "./index.js";
2
+
3
+ //#region src/webgl.d.ts
4
+ /**
5
+ * Upstream's `ATLAS_TEX_WIDTH`, and the width this renderer PREFERS rather than the one it gets.
6
+ *
7
+ * Wide and short keeps the unwrap arithmetic in `int` range. WebGL2 only guarantees
8
+ * `MAX_TEXTURE_SIZE >= 2048`, so a device can refuse 4096 — read {@link HbGpuRenderer.atlasWidth}
9
+ * for the width an instance actually built, and never assume this constant describes it. The
10
+ * shader is handed the achieved width as `hb_gpu_atlas_width`, so any width is legal.
11
+ */
12
+ declare const ATLAS_WIDTH = 4096;
13
+ /**
14
+ * THE DILATION'S WHOLE TAP BUDGET, as one number, and the reason it is one number.
15
+ *
16
+ * It bounds the flat loop in {@link FRAGMENT_MAIN} — one tap per iteration — so the compile-time
17
+ * ceiling IS the worst-case tap count: 64 unrolled sites, plus `hb_gpu_draw`'s own centre tap, is 65
18
+ * evaluations for the most expensive fragment there is. That was also the ceiling of the nested
19
+ * `4 rings x 16 steps` loops this replaced, so the redistribution below is free at the top end.
20
+ *
21
+ * WHY THE SHAPE CHANGED RATHER THAN THE NUMBER. The nested form clamped EVERY ring to the same 16
22
+ * steps, so past `radius * t > 2.5 px` all four rings ran 16 taps — and the outermost one, which is
23
+ * the only ring that decides where the dilated boundary lands, was then the SPARSEST: at radius 12
24
+ * its taps sit 4.71 px of arc apart, while ring 1 spends the same 16 on a circle a quarter the size
25
+ * and puts them 1.18 px apart. Giving
26
+ * the outer ring more steps in that shape would have meant raising the per-ring cap, and 4 x 28 is
27
+ * 112 unrolled sites for a budget most fragments never spend. A flat loop makes "how many taps may a
28
+ * fragment cost" and "how are they arranged" two independent decisions, and only the first one is a
29
+ * perf number.
30
+ *
31
+ * INTERPOLATED INTO THE GLSL rather than restated there, so `spreadBudget.test.ts` can assert on the
32
+ * arithmetic that divides it between rings and on the ceiling reaching the shader, from one source.
33
+ */
34
+ declare const HB_GPU_SPREAD_MAX_TAPS = 64;
35
+ declare const FRAGMENT_MAIN = "\nuniform vec4 u_color; /* STRAIGHT rgba; premultiplied exactly once, below */\nuniform float u_spreadPx;\nuniform float u_gamma; /* exponent on the final coverage; 1.0 is off */\nuniform float u_stemDarken; /* > 0 runs hb_gpu_stem_darken; 0 is off */\n\nin vec2 v_texcoord;\nflat in uint v_glyphLoc;\nflat in float v_spreadEm;\n\nout vec4 fragColor;\n\nconst float HB_GPU_SPREAD_TAU = 6.2831853;\n/* Hard ceilings so the loop is bounded at compile time. One tap per iteration, so\n * HB_GPU_SPREAD_MAX_TAPS + the centre tap is the worst case, and it is only reached by a fragment\n * that is neither solid ink nor near any. See the TS constant of the same name. */\nconst int HB_GPU_SPREAD_MAX_RINGS = 4;\nconst int HB_GPU_SPREAD_MAX_TAPS = 64;\n/* \"Already saturated\": no tap can raise this, so stop. Not 1.0, because the coverage estimator\n * lands a hair under it on a deep-interior fragment and an exact test would never fire. */\nconst float HB_GPU_SPREAD_SOLID = 0.999;\n\n/*\n * WHERE A TAP STOPS MEANING \"how much ink is at this offset\" AND STARTS MEANING \"is this fragment\n * inside the dilated silhouette\". The knee of a smoothstep, and the whole of the fix below.\n *\n * NO BACKTICKS ANYWHERE IN THIS COMMENT, and that is not a style note: this whole string is a JS\n * template literal, so one backtick ends the shader mid-sentence and the package fails to PARSE.\n *\n * THE BUG IT REMOVES. A dilated shape is the union of a disk of radius r swept along the outline:\n * a BINARY shape, whose only partial coverage is at its own boundary. A max of raw coverage taps\n * cannot produce that, because a max cannot exceed the largest coverage near the fragment \u2014 and at\n * ppem 14 a Han stroke is thinner than a pixel, so its coverage PEAKS at 0.42 and the whole\n * silhouette came out a translucent mottle at 0.62 of the ideal's ink.\n *\n * THE RANGE IS 0 TO 0.5, AND \"A TAP ABOVE HALF COVERAGE IS INSIDE\" IS THE RULE THAT FAILS. That is\n * the obvious reading and it makes this case measurably WORSE, which is why the knee is a swept\n * number rather than an argued one. Half of a PIXEL is not half of a sub-pixel STROKE: at ppem 14\n * the fixture's peak coverage is 0.42, so a knee centred on 0.5 sits above anything the glyph can\n * reach and ERASES the outline. What 0.5 is the right value for is the top of the range \u2014 a pixel\n * centred exactly ON the outline reads 0.5, so \"as covered as a pixel on the boundary\" is the point\n * at which a tap is fully inside, and everything below it ramps.\n *\n * SWEPT ON THE RTX 2060 THROUGH ANGLE, both fixtures, against 8x grown references. Low-ppem is\n * \u4E2D at 14 px per em rotated 10 degrees, spread 3 (SHALLOW COVERAGE); thin is a full stop at 96 px\n * per em, spread 12 (SPARSE COVERING \u2014 no 64-tap set tiles a disk of that radius; the sweep was run\n * when those taps were four rings of 16, and the rim column moved again when they were resplit).\n *\n * knee low rim rms low ink ratio low interior short thin rim rms thin ink ratio\n * none 77.89 0.622 38.5% 80.90 0.965\n * 0.35 - 0.65 128.57 0.574 (worse still) 102.52 0.966\n * 0.25 - 0.75 112.35 0.604 --- 99.72 0.965\n * 0.20 - 0.50 76.87 0.870 --- 94.27 0.974\n * 0.15 - 0.45 73.85 0.968 --- 90.15 0.978\n * 0.10 - 0.40 84.75 1.037 --- 85.82 0.985\n * 0.05 - 0.45 79.40 1.011 4.1% 82.16 0.986\n * 0.05 - 0.50 72.64 0.968 7.4% 82.81 0.983\n * 0.00 - 0.45 83.29 1.026 3.3% 78.86 0.990\n * 0.00 - 0.55 69.73 0.947 9.3% 79.96 0.984\n * 0.00 - 0.50 75.66 0.988 6.1% 79.10 0.987 <-\n *\n * The two upper rows are the \"roughly half\" hypothesis and both are worse than shipping nothing.\n * 0 - 0.5 is the only row that improves EVERY column at once, and it is also the one with a\n * sentence behind it rather than a fit.\n *\n * WHAT IT COSTS AT LARGE PPEM. A well-resolved glyph's tap coverage IS the area, so a tap sitting\n * exactly on the outline reads 0.5 \u2014 and 0.5 is also the ideal answer at the dilated boundary,\n * where this maps it to 1. So the boundary moves outward by a fraction of a pixel: measured, the\n * 96 px per em ink box grows 5 px on one side for a spread of 4 instead of 4. Real, inside the\n * fixtures' SPREAD_TOLERANCE_PX, and the price of an interior that is no longer translucent.\n *\n * PER TAP RATHER THAN ON THE MAX, AND NOT FOR THE REASON IT LOOKS LIKE. smoothstep is MONOTONE, so\n * it commutes with max and the two placements give the same silhouette \u2014 measured, not reasoned:\n * moving it after the loop reads ink ratio 0.989 against 0.988 and the same rim RMS to two decimal\n * places. What the placement actually buys is the two things a monotone identity does not cover.\n * First, the FILL IS THE FLOOR: cov enters the loop as hb_gpu_draw's own coverage and is never\n * sharpened, so a dilated run stays a strict SUPERSET of the same run at spread 0 \u2014 sharpening the\n * max would put the fill through the knee too, and smoothstep(0, 0.5, x) is BELOW x for x under\n * ~0.08, so a faint fill pixel would come back dimmer than it was drawn. Second, the early-out\n * below tests cov INSIDE the loop, and only a per-tap value can raise it early.\n *\n * IT DOES NOT MAKE THE EARLY-OUT FIRE AT 14 px, WHICH THE ROUND EXPECTED IT TO. A tap saturates to\n * exactly 1 only once its raw coverage reaches HB_GPU_SPREAD_INSIDE_HIGH, and at ppem 14 the\n * fixture's peak raw coverage is 0.42, which sharpens to 0.931 \u2014 still under\n * HB_GPU_SPREAD_SOLID. So a fragment at that size still walks the whole tap set, and the frame-cost\n * side effect that was predicted here IS NOT THERE. Above ppem 16 taps reached 1 before this change\n * as well, so nothing moved there either. Lowering HB_GPU_SPREAD_SOLID would collect it, and is\n * deliberately not done here: it is a cost decision with its own pixels to grade, on a rung where\n * the tap budget is a device ceiling.\n */\nconst float HB_GPU_SPREAD_INSIDE_LOW = 0.0;\nconst float HB_GPU_SPREAD_INSIDE_HIGH = 0.5;\n\n/*\n * One coverage tap, WITH NO DERIVATIVE IN IT \u2014 which is the whole reason this exists.\n *\n * It is _hb_gpu_slug (hb-gpu-fragment.glsl, 14.4.0) with ppem lifted into a parameter. The\n * library's own _hb_gpu_slug advertises itself as callable \"from non-uniform control flow\", and\n * for GLSL it is not: it calls hb_gpu_ppem, which calls fwidth. The disk below has a per-fragment\n * early-out, so every tap after that point IS non-uniform control flow, and a fwidth there is\n * undefined by GLSL ES 3.00.\n *\n * MIRRORED RATHER THAN AVOIDED so an outline tap and a fill fragment agree. Lifting ppem is exact\n * rather than an approximation: it is fwidth(v_texcoord) and the glyph's own scale, and fwidth of an\n * interpolated varying is constant across an affine quad, so its value at a tap equals its value at\n * the centre.\n *\n * THE MSAA HALF IS SWITCHABLE AND THE DEFAULT IS OFF \u2014 see\n * {@link HbGpuRendererOptions.spreadTapMsaa}, which carries the measurement. Note the macro is\n * HB_GPU_SPREAD_TAP_NO_MSAA and NOT the library's HB_GPU_NO_MSAA: that one guards _hb_gpu_slug,\n * i.e. the FILL, which this trade must not touch.\n *\n * The vendored wasm is digest-pinned (vendor/VENDOR.md, test/vendor.test.ts), so the source this\n * mirrors cannot move without a deliberate vendor bump.\n */\nfloat hb_gpu_spread_tap (vec2 rc, vec2 pixelsPerEm, float ppem, uint glyphLoc_)\n{\n float c = _hb_gpu_slug_single (rc, pixelsPerEm, glyphLoc_);\n#ifndef HB_GPU_SPREAD_TAP_NO_MSAA\n if (ppem < 16.0)\n {\n vec2 emsPerPixel = 1.0 / pixelsPerEm;\n vec2 d = emsPerPixel * (1.0 / 3.0);\n float msaa = 0.25 *\n (_hb_gpu_slug_single (rc + vec2 (-d.x, -d.y), pixelsPerEm, glyphLoc_) +\n _hb_gpu_slug_single (rc + vec2 ( d.x, -d.y), pixelsPerEm, glyphLoc_) +\n _hb_gpu_slug_single (rc + vec2 (-d.x, d.y), pixelsPerEm, glyphLoc_) +\n _hb_gpu_slug_single (rc + vec2 ( d.x, d.y), pixelsPerEm, glyphLoc_));\n c = mix (c, msaa, smoothstep (16.0, 8.0, ppem));\n }\n#endif\n return c;\n}\n\nvoid main ()\n{\n float cov = hb_gpu_draw (v_texcoord, v_glyphLoc);\n\n /* UNIFORM CONTROL FLOW, AND IT HAS TO BE: the two derivative-taking calls inside are legal only\n * because every fragment of a 2x2 derivative quad takes this branch together. u_spreadPx is a\n * real uniform and v_spreadEm is flat (constant over a primitive, which is what a derivative\n * quad belongs to), so it is uniform by construction rather than by luck. v_spreadEm is also a\n * genuine guard: a NaN or zero a_emPerPos fails it and takes the single-tap path. */\n if (u_spreadPx > 0.0 && v_spreadEm > 0.0)\n {\n vec2 pixelsPerEm = 1.0 / fwidth (v_texcoord);\n float ppem = hb_gpu_ppem (v_texcoord, v_glyphLoc);\n /* The spread in DEVICE pixels \u2014 fwidth is a screen-space derivative, so this already carries\n * the model scale and the device-pixel ratio. It only ever picks tap counts; the tap OFFSETS\n * are in em units and are exact. */\n float radiusPx = v_spreadEm * max (pixelsPerEm.x, pixelsPerEm.y);\n\n /* CONCENTRIC RINGS, NOT ONE, and that is not a refinement. A dilated fragment is covered iff\n * SOME offset within the disk lands on ink; taps on a single ring of radius r can all overshoot\n * a feature narrower than 2r, which punches holes through the outline exactly where a glyph is\n * thin \u2014 a comma, a hairline serif, a full stop. Ring spacing is held near 2/3 px, so the RADIAL\n * half of the covering is sub-pixel out to the clamp at HB_GPU_SPREAD_MAX_RINGS.\n *\n * THE RADII STAY EQUALLY SPACED, which is worth saying because equal AREA is the obvious\n * alternative and it is worse here. Pushing the rings outward concentrates them where the taps\n * are already densest, and combined with the budget split below it both doubles the outward bias\n * of the whole tap set and halves the radial margin the small-feature case relies on \u2014 the one\n * where a full stop smaller than the tap radius has to be found by an INNER ring. */\n int rings = clamp (int (ceil (radiusPx * 1.5)), 1, HB_GPU_SPREAD_MAX_RINGS);\n /* 1 + 2 + ... + rings, the denominator of the budget split below. */\n int denom = rings * (rings + 1) / 2;\n\n /* ONE FLAT LOOP OVER THE WHOLE BUDGET, AND THE OUTER RING GETS MOST OF IT.\n *\n * Every ring used to be capped at the same number of steps, which sounds neutral and is not: a\n * ring's taps are spread over a circumference proportional to its radius, so an equal share puts\n * the WIDEST arc gaps on the outermost ring \u2014 the only one that decides where the dilated\n * boundary lands. At radius 12 that was 4.71 px of arc between the taps that draw the edge,\n * against 1.18 px on ring 1, and the boundary followed the tap count: measured against a Godot\n * 4.5.1 golden, 0.3099 px of wobble at exactly 16 cycles per revolution where the engine has\n * 0.0164.\n *\n * So the budget is split in proportion to ring RADIUS, i.e. to circumference: ring k of rings\n * may spend (MAX_TAPS * k + denom/2) / denom taps, which at four rings is 6 / 13 / 19 / 26 and\n * sums to exactly MAX_TAPS. It sums to exactly MAX_TAPS at one, two and three rings as well\n * (64; 21 + 43; 11 + 21 + 32), so the flat bound is never the thing that truncates a ring \u2014 it\n * is a hedge against a driver that insists on unrolling, not a second policy. At radius 12 the\n * outer arc is then 2.90 px rather than 4.71.\n *\n * The lower clamp of 6 steps is what keeps a SMALL radius honest, and it is the reason the cap\n * enters as max (cap, 6) rather than as cap: ring 1's share at four rings is exactly 6, and a\n * hexagon is the coarsest ring that still surrounds its centre.\n *\n * ONE TAP PER ITERATION, so HB_GPU_SPREAD_MAX_TAPS is simultaneously the loop bound and the\n * fragment's worst-case cost \u2014 the two used to be 4 x 16 and 65 and had to be reasoned about\n * separately. Dynamic bounds and breaks are legal ESSL 3.00; the GLSL ES 1.00 Appendix A\n * restriction that forced the nested constant shape does not apply to version 300 es. */\n int ring = 0;\n int step = 0;\n int steps = 0;\n float ringEm = 0.0;\n float phase = 0.0;\n for (int i = 0; i < HB_GPU_SPREAD_MAX_TAPS; i++)\n {\n /* THE INTERIOR EARLY-OUT: a fragment already covered by its own centre tap is trivially\n * within r of ink, and interior fragments are most of a glyph. */\n if (cov >= HB_GPU_SPREAD_SOLID) break;\n if (step >= steps)\n {\n ring += 1;\n /* The other exit: the rings this radius actually asked for are done. */\n if (ring > rings) break;\n float t = float (ring) / float (rings);\n ringEm = v_spreadEm * t;\n int cap = (HB_GPU_SPREAD_MAX_TAPS * ring + denom / 2) / denom;\n steps = clamp (int (ceil (HB_GPU_SPREAD_TAU * radiusPx * t)), 6, max (cap, 6));\n /* THE GOLDEN ANGLE, so no two rings put their taps on the same radii \u2014 which would leave\n * wedge-shaped gaps between the rings rather than a covering. A fixed fraction of a step\n * would do that for one pair of ring counts and line up for another; 137.5 degrees per ring\n * is the rotation with no small-integer commensurability with any of them. */\n phase = 2.39996 * float (ring);\n step = 0;\n }\n float angle = phase + HB_GPU_SPREAD_TAU * float (step) / float (steps);\n vec2 at = v_texcoord + ringEm * vec2 (cos (angle), sin (angle));\n /* MAX, NOT A SUM, AND THE MAX IS WHY THIS IS IN THE SHADER. The alternative a caller could\n * build without it \u2014 draw the run N times at N offsets \u2014 composites N times, so a\n * translucent outline is N overlapping translucent copies and reads far darker than one\n * stroke. One fragment, one coverage, one blend.\n *\n * SHARPENED BEFORE THE MAX, not after: the max is over a set of INSIDE tests, and the union\n * of disks it approximates is a binary shape. Sharpening the max instead would sharpen a\n * number that had already been flattened to the peak coverage nearby, which is the value\n * that is wrong. See HB_GPU_SPREAD_INSIDE_LOW. */\n cov = max (cov, smoothstep (HB_GPU_SPREAD_INSIDE_LOW,\n HB_GPU_SPREAD_INSIDE_HIGH,\n hb_gpu_spread_tap (at, pixelsPerEm, ppem, v_glyphLoc)));\n step += 1;\n }\n }\n\n /* CONTRAST, ON THE FINAL COVERAGE \u2014 and STEM DARKENING ONLY WHEN THE DILATION DID NOT RUN.\n *\n * An outline's rim is NOT a coverage ramp with the same problem the fill's has, which is what\n * this block assumed when it shipped. Stem darkening exists because a sub-pixel STEM lands at\n * mid-grey under linear coverage where a browser puts it near the ink colour; the fix is an\n * exponent that pushes the middle of the ramp toward the foreground. A dilated fragment's\n * coverage is not that number. The taps above are an INSIDE test (HB_GPU_SPREAD_INSIDE_LOW\n * sharpens each one before the max), so the dilated boundary is already very nearly binary \u2014 and\n * the engine this mirrors applies no curve at all to its outline: Godot strokes the glyph and\n * hands the result to FreeType's plain grayscale raster. Measured against that golden, the curve\n * took a 14 px outline's rim from 0.662 px to 1.965 px of equivalent ramp, three times the width,\n * where Godot's own is 0.851. That is a halo \u2014 fattening for dark ink, thinning for light \u2014 and\n * it is the one thing in this shader that made an outline softer than the engine's.\n *\n * THE GATE IS THE DILATION BRANCH'S OWN CONDITION, CHARACTER FOR CHARACTER, so the two cannot\n * disagree about which fragments dilated. A bare u_spreadPx > 0.0 would also strip the\n * correction from a DEGENERATE run \u2014 one whose a_emPerPos is zero or NaN, which fails\n * v_spreadEm > 0.0 and takes the single-tap path. Such a fragment is a fill in every way that\n * reaches the framebuffer, and it should keep a fill's darkening rather than lose it to a uniform\n * that ended up doing nothing.\n *\n * GAMMA IS NOT GATED. It is an explicit consumer knob, defaults to 1 (skipped entirely below),\n * and is polarity- and size-blind by construction; a consumer that deliberately sets one is\n * asking for a transfer curve on the text, and putting the fill and the outline of the same run\n * on different curves would be a stranger thing than either choice.\n *\n * THE PPEM IS FETCHED IN UNIFORM CONTROL FLOW AND THE COVERAGE TEST IS NOT ALLOWED TO CONTAIN IT.\n * hb_gpu_ppem calls fwidth, which GLSL ES 3.00 leaves undefined once the 2x2 derivative quad can\n * disagree about whether to run it \u2014 and \"0.0 < cov < 1.0\" is precisely a per-fragment condition.\n * u_stemDarken and u_spreadPx are real uniforms and v_spreadEm is flat, which is the same\n * argument the dilation branch above makes for its own hb_gpu_ppem call; darken is that branch's\n * predicate negated, so a quad coherent enough to run the dilation is exactly as coherent about\n * skipping the darkening. Upstream's demo puts its fwidth inside the coverage test; that is a\n * desktop-GLSL liberty this cannot take.\n */\n bool fillPass = !(u_spreadPx > 0.0 && v_spreadEm > 0.0);\n bool darken = u_stemDarken > 0.0 && fillPass;\n\n float darkenPpem = 0.0;\n if (darken)\n darkenPpem = hb_gpu_ppem (v_texcoord, v_glyphLoc);\n\n /* EDGE ONLY, which is upstream's guard and is not merely an optimisation here. Both corrections\n * fix 0 and 1, so the interior and the background cannot move whatever the exponents are \u2014 but\n * pow (0.0, y) is UNDEFINED for y <= 0 in GLSL ES 3.00, and u_gamma is a number a consumer chose.\n * Skipping cov == 0.0 is what makes a hostile gamma a picture that is wrong rather than a NaN\n * alpha over the whole quad. No derivative is taken inside, so a non-uniform branch is legal. */\n if (cov > 0.0 && cov < 1.0)\n {\n float adj = cov;\n if (darken)\n {\n /* u_color is STRAIGHT, so this IS upstream's dot (c.rgb, 1/3) / c.a with the divide already\n * done. A flat 1/3 rather than a Rec.709 luma on purpose: hb_gpu_stem_darken's exponent\n * curve is calibrated against HarfBuzz's own definition of \"brightness\", and weighting the\n * channels differently would silently retune somebody else's constants. */\n adj = hb_gpu_stem_darken (adj, dot (u_color.rgb, vec3 (1.0 / 3.0)), darkenPpem);\n }\n if (u_gamma != 1.0)\n adj = pow (adj, u_gamma);\n cov = adj;\n }\n\n float a = u_color.a * cov;\n fragColor = vec4 (u_color.rgb * a, a);\n}\n";
36
+ /**
37
+ * A face registered with the renderer: the namespace every one of its glyph keys carries.
38
+ *
39
+ * THE ATLAS KEY IS `(face, glyph)` AND NOT `glyph`, AND THAT IS THE WHOLE POINT OF THIS TYPE.
40
+ * Glyph 42 of Noto Sans SC and glyph 42 of Roboto are unrelated outlines; an atlas keyed on the
41
+ * glyph id alone hands the second one the first one's texels and renders fluent, crisp, WRONG
42
+ * text — the one failure mode nothing downstream measures. That namespacing used to live in the
43
+ * perf-harness's key strings, one layer above a renderer that could not tell two faces apart.
44
+ *
45
+ * Registering also fixes the `upem`, so {@link HbGpuRenderer.upload} no longer takes one and a
46
+ * glyph cannot be uploaded under one face's scale and drawn at another's.
47
+ */
48
+ interface HbGpuFace {
49
+ /** Dense index assigned at registration. Namespaces every key of this face. */
50
+ readonly id: number;
51
+ /** Whatever the embedder called it. Appears in failure messages, nowhere else. */
52
+ readonly label: string;
53
+ /** Units per em, taken from the registered font and never from a caller's argument. */
54
+ readonly upem: number;
55
+ }
56
+ /** Where one encoded glyph landed, and the box to draw it in. */
57
+ interface GlyphSlot {
58
+ /**
59
+ * Numeric identity of the resident glyph. Optional so structural slots made by older callers
60
+ * remain accepted by the cold compatibility path in {@link HbGpuRenderer.push}.
61
+ */
62
+ faceId?: number;
63
+ glyphId?: number;
64
+ /**
65
+ * The allocator key — `<face id>/<glyph id>`, so a draw can find its allocation without a
66
+ * reverse lookup. Internal shape: read it for debugging, never construct one.
67
+ */
68
+ key: string;
69
+ /**
70
+ * Which ALLOCATION this slot describes, stamped by the allocator and never reused.
71
+ *
72
+ * THE ANSWER TO THE WORST BUG THIS PACKAGE HAD. The ring evicts, so a slot handed out an hour
73
+ * ago can name texels that some other glyph now owns. `push` used to draw it anyway, at the
74
+ * right size, in the right place, perfectly antialiased — a different glyph's outline, invisible
75
+ * to every check downstream. `push` now compares this against the live allocation and skips on a
76
+ * mismatch (see {@link AtlasStats.staleSkips}); a plain key comparison could not, because the
77
+ * key of a re-uploaded glyph is the same key.
78
+ */
79
+ generation: number;
80
+ /** Atlas texel index of the blob's first texel — the shader's `glyphLoc`. */
81
+ loc: number;
82
+ /** Units per em of the face these coordinates are in. */
83
+ upem: number;
84
+ /** Em-space ink box, y-UP, as `hb_gpu_draw_encode` reported it. */
85
+ minX: number;
86
+ minY: number;
87
+ maxX: number;
88
+ maxY: number;
89
+ /** Texels this blob occupies. */
90
+ texels: number;
91
+ }
92
+ interface AtlasStats {
93
+ /**
94
+ * What the live allocations actually occupy: texels x 8.
95
+ *
96
+ * The honest "how much glyph data is resident" number, and the one to compare against a baked
97
+ * atlas's occupied bytes.
98
+ */
99
+ liveBytes: number;
100
+ /**
101
+ * `width x height x 8` — the whole texture.
102
+ *
103
+ * THE NUMBER THAT SITS NEXT TO `hb-atlas`'s 1.45 MiB. A driver allocates the texture, not the
104
+ * used part of it, so this is what is actually held whatever the occupancy says. Reported
105
+ * separately from `liveBytes` because quoting only the smaller one is how a renderer appears to
106
+ * cost less than it does.
107
+ */
108
+ reservationBytes: number;
109
+ /** Live allocations, and the texels they hold. */
110
+ entries: number;
111
+ liveTexels: number;
112
+ capacityTexels: number;
113
+ /** Allocations overwritten by a wrapped cursor since creation. */
114
+ evictions: number;
115
+ /** Faces registered. Keys are namespaced by these — see {@link HbGpuFace}. */
116
+ faces: number;
117
+ /**
118
+ * Glyphs `push` declined to draw because their slot no longer matches a live allocation.
119
+ *
120
+ * NON-ZERO IS A REAL FINDING, not noise: it means the embedder is holding slots across an
121
+ * eviction (its atlas is too small for its working set, or it caches slots it should re-`upload`)
122
+ * and the frame is short of glyphs. Counted rather than thrown because `push` is the hot loop.
123
+ */
124
+ staleSkips: number;
125
+ }
126
+ interface BlobStats {
127
+ /** Distinct glyphs uploaded. */
128
+ glyphs: number;
129
+ /** Sum of every uploaded blob's length, in bytes. */
130
+ totalBytes: number;
131
+ /**
132
+ * THE PREDICTION UNDER TEST. `docs/text-rendering.md` records "~5.4 KB per Han glyph against
133
+ * ~1.4 KB for a 38x38 R8 atlas cell". This is the left-hand side of it, self-counted.
134
+ */
135
+ bytesPerGlyph: number;
136
+ }
137
+ /**
138
+ * The contrast correction the fragment stage applies to the coverage it computed.
139
+ *
140
+ * THE TWO FIELDS REACH DIFFERENT PASSES, which is the one thing to read before setting either:
141
+ * `gamma` is applied to every draw, `stemDarkening` only to an undilated one. The reason is under
142
+ * {@link HbGpuContrast.stemDarkening}.
143
+ *
144
+ * WHY IT EXISTS, AND WHY THE DEFAULT IS ON. Raw analytic coverage is the AREA of the pixel the
145
+ * outline covers, and compositing it linearly is not what a browser does: measured on one fixed
146
+ * crop of the word "Breakthrough" at 1600x900 / DPR 1.25, the hb-gpu path and the DOM path agree on
147
+ * peak darkness (51 vs 50), on mean luminance (120.5 vs 121.0) and on total ink (2736 vs 2744) —
148
+ * and DOM still puts **66% more pixels** in the deep-dark end (1285 below luma 80 against 775).
149
+ * That gap is entirely in the middle of the ramp: a sub-pixel stem lands mid-grey here and near the
150
+ * ink colour there, which reads as washed out at exactly the sizes a UI uses. Every shipping
151
+ * consumer wants the correction; the exception is a harness.
152
+ *
153
+ * BOTH FIELDS ARE REQUIRED rather than optional, which is deliberate. A half-specified
154
+ * `{ gamma: 1.2 }` would silently inherit a stem-darkening default the author never considered, and
155
+ * this is a knob whose whole purpose is that somebody thought about it. Use
156
+ * {@link HB_GPU_CONTRAST_DEFAULT} / {@link HB_GPU_CONTRAST_NONE} rather than writing the pair out.
157
+ */
158
+ interface HbGpuContrast {
159
+ /**
160
+ * Exponent applied to the coverage. `1` is the identity and is the default.
161
+ *
162
+ * BELOW 1 IS DARKER (a coverage of 0.5 moves toward 1), above 1 lighter. It is polarity-BLIND —
163
+ * unlike `stemDarkening`, which reads the foreground — so a value that helps dark-on-light text
164
+ * hurts light-on-dark by the same amount. HarfBuzz's own demo flips it by theme
165
+ * (`demo-view.cc`: `dark_mode ? 1/2.2 : 2.2`) for that reason, and a renderer here draws both
166
+ * polarities in one frame and cannot. Hence 1: the size- and polarity-aware half of the
167
+ * correction is `stemDarkening`, and this is the manual override next to it.
168
+ *
169
+ * Non-finite or non-positive values are refused and reported (`"degenerate-contrast"`), because
170
+ * `pow` with such an exponent is a NaN alpha over the whole quad rather than a wrong picture.
171
+ */
172
+ gamma: number;
173
+ /**
174
+ * Run `hb_gpu_stem_darken` on the coverage OF A FILL. Default `true`.
175
+ *
176
+ * IT IS THE SIZE-AWARE AND POLARITY-AWARE HALF. The library's exponent is
177
+ * `mix (pow (2, brightness - 0.5), 1, smoothstep (8, 48, ppem))`, so it fattens dark text
178
+ * (brightness 0 -> exponent 0.707), thins light text (brightness 1 -> 1.414) and RAMPS ITSELF OFF
179
+ * by ppem 48, where a stem is wide enough that no pixel of it is partially covered anyway. The
180
+ * brightness comes from the per-instance colour and the ppem from `hb_gpu_ppem`, so it costs no
181
+ * per-frame decision.
182
+ *
183
+ * IT DOES NOT APPLY TO A DILATED RUN, whatever this flag says: a draw with a non-zero
184
+ * {@link HbGpuRenderer.setSpread} emits raw coverage. The correction exists because a sub-pixel
185
+ * STEM sits at mid-grey under linear coverage where a browser puts it near the ink colour, and a
186
+ * dilated fragment's coverage is not that number — the taps are an inside test, so the boundary
187
+ * is nearly binary before any curve touches it. More decisively, Godot's outline is a plain
188
+ * FreeType raster with no curve of its own, so an exponent on a dilated rim reads as a HALO
189
+ * against it: fattening for dark ink, thinning for light. Measured against the committed Godot
190
+ * golden, it took a 14 px outline's rim from 0.662 to 1.965 px of equivalent ramp where Godot's
191
+ * is 0.851 (`packages/hb-gpu/test/goldens/godot-outline-metrics.json`, `docs/text-rendering.md`).
192
+ *
193
+ * THE EDGE THAT LEAVES: a consumer drawing a spread run as the ONLY ink — an outline with no fill
194
+ * composited over it — now gets uncorrected coverage for that text and no way to ask for
195
+ * otherwise. That is the intended picture for a stroke and the wrong one for a glyph body, so a
196
+ * caller in that position should draw the fill it is standing in for. `gamma` is NOT gated and
197
+ * remains available for a deliberate transfer curve over both passes.
198
+ */
199
+ stemDarkening: boolean;
200
+ }
201
+ /** Stem darkening on, gamma neutral. What a renderer built without a `contrast` option gets. */
202
+ declare const HB_GPU_CONTRAST_DEFAULT: HbGpuContrast;
203
+ /**
204
+ * No contrast curve at all: the fragment writes the coverage it computed.
205
+ *
206
+ * FOR MEASUREMENT ARMS, and they should say so where they pass it. A fidelity probe that grades an
207
+ * arm against an 8x area-coverage reference is grading the RASTERIZER, and an arm carrying a
208
+ * contrast curve scores the curve instead — `docs/text-rendering.md`'s distortion figures (0.196
209
+ * Han at ppem 14, 0.017 at ppem 49) only mean what they say against raw coverage.
210
+ */
211
+ declare const HB_GPU_CONTRAST_NONE: HbGpuContrast;
212
+ interface HbGpuRendererOptions {
213
+ /**
214
+ * The context to draw in. NOT created here and never destroyed here.
215
+ *
216
+ * It must have been created with `premultipliedAlpha: true` — see this file's header. Nothing
217
+ * can check that from inside (`getContextAttributes` reports what was ASKED for, and the failure
218
+ * is a picture that is merely darker), so it is stated rather than validated.
219
+ */
220
+ gl: WebGL2RenderingContext;
221
+ /**
222
+ * The extent of OBJECT SPACE — the units {@link HbGpuRenderer.push} takes, y measured DOWN.
223
+ *
224
+ * This pair builds the design->clip projection and nothing else. On a standalone canvas it is
225
+ * the device-pixel size and equals the framebuffer pair below; on a DPR-scaled stage it is the
226
+ * scene's own coordinate extent (`packages/canvas/src/present.ts`'s `designWidth`), and the two
227
+ * pairs differ by the ratio.
228
+ */
229
+ designWidth: number;
230
+ designHeight: number;
231
+ /**
232
+ * The ACHIEVED drawing-buffer size, in DEVICE pixels. Defaults to the design pair.
233
+ *
234
+ * NOT THE SAME NUMBER AS THE DESIGN PAIR, and the default is only correct for a stage whose
235
+ * device-pixel ratio is 1. This pair is `u_viewport`, which is what `hb_gpu_dilate` measures half
236
+ * a SCREEN pixel against — see this file's header for what feeding it design units does.
237
+ *
238
+ * PASS `gl.drawingBufferWidth`, not the size you asked the canvas for. Setting `canvas.width`
239
+ * only REQUESTS an allocation and an implementation may hand back less, and a viewport that is
240
+ * wrong by a few pixels is a dilation that is wrong by a fraction of one — a rim of clipped
241
+ * antialiasing around every glyph rather than anything that looks like a size error.
242
+ */
243
+ framebufferWidth?: number;
244
+ framebufferHeight?: number;
245
+ /**
246
+ * Atlas capacity in TEXELS. Rounded up to a whole number of {@link HbGpuRenderer.atlasWidth}-wide
247
+ * rows.
248
+ *
249
+ * Default 256 rows = 1 Mi texels = 8 MiB, which holds ~1500 Han outlines at the ~5.4 KB the
250
+ * prediction expects. Sized in texels rather than bytes because that is the unit the shader
251
+ * indexes in and the unit the allocator wraps in.
252
+ */
253
+ atlasTexels?: number;
254
+ /**
255
+ * Give each DILATION TAP the same five-sample average `_hb_gpu_slug` gives a fill below ppem 16.
256
+ *
257
+ * **Default `false`, and that default is a measured trade rather than an oversight.**
258
+ *
259
+ * WHAT IT COSTS TO LEAVE ON. `hb_gpu_spread_tap` mirrors `_hb_gpu_slug`, so below ppem 16 one ring
260
+ * tap becomes FIVE `_hb_gpu_slug_single` evaluations — and a dilated fragment takes up to 65 taps.
261
+ * Measured on S9's `text-render` scenario (RTX 2060, 1280x800 at DPR 1, 40 outlined runs at 14 px,
262
+ * `outlinePx` 6): **12.27 Hz with it on, 70.47 Hz with it off**, 5.74x, and 8.05x at `outlinePx`
263
+ * 10. The font-size ladder isolates it — the same 3 px radius and the same tap set (47 steps when
264
+ * that was measured, 50 under the budget split that replaced the per-ring clamp) reads 17.44 Hz at
265
+ * `fontSize` 18 and 74.76 at 20, because 20 is where the ppem the shader computes crosses 16 and
266
+ * the branch stops firing. It is one branch, not the tap count: capping the rings and steps at
267
+ * 3x12 bought only 1.5x, and 2x8 bought 2.3x by punching holes through thin features.
268
+ *
269
+ * WHY IT IS DEFENSIBLE TO LEAVE OFF, which is a claim about pixels and is pinned by
270
+ * `test/glyphPixelXvfb.test.ts`'s low-ppem pair. The outline is a solid silhouette that a fill is
271
+ * then drawn on top of, so the only thing the taps decide is its OUTER rim; a max over ~47 taps is
272
+ * itself a smoothing operator; and the rim of a thick outline is the least legible place in a
273
+ * glyph. Measured at 14 px, rotated 10 degrees, spread 3, both programs on one GPU in one frame:
274
+ * the two differ on **267 pixels, RMS 33.4 levels of 255, worst 85** — and the one WITHOUT the
275
+ * MSAA is the one closer to an 8x dilated reference (rim RMS 77.9 against 85.1, ink 36102 against
276
+ * 30192 where the ideal grown shape is 58081). The extra smoothing was deepening a shortfall, not
277
+ * repairing one: `max` over coverage cannot exceed the peak coverage near a fragment, and at
278
+ * ppem 14 a Han glyph's strokes never reach 1, so the outline is a translucent mottle at ~60% of
279
+ * the ideal either way. That is the honest shape of this trade — not "5.74x for a slightly
280
+ * coarser rim" but "5.74x for a differently-wrong outline at a size where the outline is already
281
+ * wrong". Above ppem 16 it is free in both directions: the blend weight is
282
+ * `smoothstep (16, 8, ppem)`, which is exactly 0 there, so NO pixel of text at or above ppem 16
283
+ * can change — measured, by widening the gate to `ppem < 200` and reading a byte-identical frame.
284
+ *
285
+ * WHAT IT DOES NOT TOUCH, structurally. The FILL's coverage is `hb_gpu_draw` -> `_hb_gpu_slug`
286
+ * inside the vendored library, guarded by the library's own `HB_GPU_NO_MSAA`. This flag defines
287
+ * `HB_GPU_SPREAD_TAP_NO_MSAA`, a different name that only this file's function reads, so no
288
+ * setting of it can reach the fill. `spread 0` never calls the tap at all.
289
+ *
290
+ * Set `true` for very small outlined text where the rim matters more than the frame budget.
291
+ */
292
+ spreadTapMsaa?: boolean;
293
+ /**
294
+ * The contrast curve applied to the FINAL coverage. Defaults to {@link HB_GPU_CONTRAST_DEFAULT},
295
+ * which has stem darkening ON.
296
+ *
297
+ * PASS {@link HB_GPU_CONTRAST_NONE} IF YOU ARE MEASURING FIDELITY, and only then. See
298
+ * {@link HbGpuContrast} for the whole argument.
299
+ */
300
+ contrast?: HbGpuContrast;
301
+ /**
302
+ * Store model/colour/spread beside every glyph so adjacent runs may share one draw. Off by
303
+ * default: the established renderer keeps its 40-byte record and uniform state unchanged.
304
+ */
305
+ perInstanceRunState?: boolean;
306
+ /**
307
+ * Where a refusal goes.
308
+ *
309
+ * `createHbGpuRenderer` returns `null` rather than throwing, following `createCanvasStage`, so a
310
+ * consumer can fall back to a DOM text path. But a renderer that declined silently reports as a
311
+ * cheap one — in a perf arm literally so — so every `null` and every declined upload also comes
312
+ * through here with a reason a human can act on.
313
+ */
314
+ onError?(failure: HbGpuFailure): void;
315
+ }
316
+ interface HbGpuRenderer {
317
+ /** The borrowed context. Owned by the embedder; `dispose` does not touch it. */
318
+ readonly gl: WebGL2RenderingContext;
319
+ /**
320
+ * The atlas width this instance actually built — NOT necessarily {@link ATLAS_WIDTH}.
321
+ *
322
+ * WebGL2 guarantees only 2048, so a device can force a narrower texture. The shader gets this as
323
+ * `hb_gpu_atlas_width` and the row-wrap arithmetic uses it, so a non-4096 width is correct and
324
+ * merely costs more `texSubImage2D` calls per blob.
325
+ */
326
+ readonly atlasWidth: number;
327
+ /**
328
+ * True between {@link notifyContextLost} and a successful {@link rebuild}.
329
+ *
330
+ * While it is true every method here is a no-op that touches no GL: `upload` returns `null`,
331
+ * `push` skips, `end` reports a zero frame. Nothing polls `gl.isContextLost()` — that is a query
332
+ * in the hot path for an event the embedder already receives — so a consumer that does not wire
333
+ * `webglcontextlost` through to {@link notifyContextLost} will draw into dead objects forever,
334
+ * which is permanently blank text with no signal. That wiring is not optional.
335
+ */
336
+ readonly contextLost: boolean;
337
+ /**
338
+ * Register a face, taking its `upem`, and get the namespace its glyph keys carry.
339
+ *
340
+ * THE FONT IS RETAINED, and that is the memory decision this package makes. A context loss
341
+ * destroys the atlas texture, so {@link rebuild} has to be able to put the same texels back at
342
+ * the same offsets — and the two ways to do that are to keep a copy of every uploaded blob or to
343
+ * keep the encoder that produced them. This keeps the ENCODER: the embedder already holds the
344
+ * `HbGpuFont` (it cannot encode without one), so the retained reference costs zero additional
345
+ * bytes, where retaining blobs would hold a second resident copy of the glyph data forever —
346
+ * 1.3 MB on S9's 300-glyph Han pool — to make a once-in-a-session event faster. The cost is paid
347
+ * on restore instead: `rebuild` re-runs `hb_gpu_draw_encode` for every resident glyph (~180 ms
348
+ * for that same pool).
349
+ *
350
+ * Consequence, stated because it is a lifetime rule and not a preference: the font must outlive
351
+ * the renderer, or a rebuild silently drops that face's glyphs.
352
+ *
353
+ * `null` when the font's `upem` is not usable — see {@link HbGpuFailureReason} `"degenerate-upem"`.
354
+ */
355
+ registerFace(font: HbGpuFont, label?: string): HbGpuFace | null;
356
+ /**
357
+ * Upload one encoded glyph of `face`, or return the slot it already has.
358
+ *
359
+ * `null` for a glyph with no ink — a space encodes to a zero-length blob, which is a legitimate
360
+ * result and must not become a zero-texel allocation — and also for a blob this renderer
361
+ * declines (malformed, or larger than the whole atlas), which is reported through `onError`.
362
+ *
363
+ * THROWS in exactly one case; see {@link HbGpuRendererOptions.atlasTexels} and the in-use guard
364
+ * inside `allocate`.
365
+ */
366
+ upload(face: HbGpuFace, glyphId: number, glyph: EncodedGlyph): GlyphSlot | null;
367
+ /**
368
+ * The live slot for a glyph that is already resident, or `null` when it is not.
369
+ *
370
+ * THE CHEAP HALF OF {@link HbGpuRenderer.upload}, AND THE REASON AN EMBEDDER CAN KEEP HANDLES
371
+ * INSTEAD OF SLOTS. A retained draw list records a glyph as an id and has to turn it back into a
372
+ * `GlyphSlot` every frame; the only way to do that used to be `upload`, which needs an
373
+ * {@link EncodedGlyph} — and encoding a Han working set costs ~180 ms, so a per-frame encode is
374
+ * not a path anybody can take. This is a map lookup and a touch.
375
+ *
376
+ * `null` means "never uploaded, or evicted", and those are the same answer for a caller: encode
377
+ * and `upload` again. It is NOT an error and nothing is reported — a ring allocator evicting is
378
+ * the mechanism working.
379
+ *
380
+ * The returned slot is built by the same expression `upload` uses, so a resolved slot and a
381
+ * freshly uploaded one cannot differ; in particular it carries the CURRENT
382
+ * {@link GlyphSlot.generation}, which is what makes it safe to `push`.
383
+ */
384
+ resolve(face: HbGpuFace, glyphId: number): GlyphSlot | null;
385
+ /** Start a frame. */
386
+ begin(): void;
387
+ /**
388
+ * Queue one glyph, its em ORIGIN (the pen position, on the baseline) at object-space `(x, y)`,
389
+ * at `pixelsPerEm` object units per em.
390
+ *
391
+ * Object space is device pixels and y measures DOWN, matching every other renderer in the repo.
392
+ * Rotation is not here on purpose: it belongs in {@link setModel}, so that one matrix rotates the
393
+ * quad AND is seen by `hb_gpu_dilate`, which computes its half-pixel dilation in SCREEN space
394
+ * through that same matrix. A quad rotated on the CPU behind the shader's back would be dilated
395
+ * along the wrong axes.
396
+ *
397
+ * A slot whose allocation has been evicted is SKIPPED and counted — see {@link GlyphSlot.generation}.
398
+ */
399
+ push(slot: GlyphSlot, x: number, y: number, pixelsPerEm: number): void;
400
+ /** Object-space 2x3 (`[xx, xy, yx, yy, tx, ty]`), applied before the projection. */
401
+ setModel(model: ArrayLike<number>): void;
402
+ /** STRAIGHT rgba in 0..1. The fragment premultiplies it, once. */
403
+ setColor(r: number, g: number, b: number, a: number): void;
404
+ /**
405
+ * Grow every glyph of the next frame outward by `px` OBJECT units. `0` (the default) is the
406
+ * plain fill.
407
+ *
408
+ * WHAT AN OUTLINED LABEL IS: this run in the outline colour at `spread`, then the SAME run in the
409
+ * fill colour at spread 0, in that order. A centred `ctx.strokeText` of width `W` reaches `W / 2`
410
+ * outward, so a caller matching one passes `W / 2`; the arithmetic is the caller's, because only
411
+ * the caller knows whether its stroke is centred, inner or outer.
412
+ *
413
+ * A DILATION FILLS THE INTERIOR AND A CENTRED STROKE DOES NOT, AND THAT DIFFERENCE IS REAL. This
414
+ * paints the whole glyph plus a band of `spread` around it, where a stroke paints only a band
415
+ * straddling the contour. Under an OPAQUE fill the two are pixel-identical, because the fill
416
+ * covers every pixel they disagree about. Under a TRANSLUCENT fill they are not: the outline
417
+ * colour shows through the glyph's middle here and would not through a stroke. That is the honest
418
+ * limit of a coverage-max dilation and there is no distance field to do better with — see
419
+ * {@link FRAGMENT_MAIN}.
420
+ *
421
+ * OBJECT UNITS, BEFORE {@link HbGpuRenderer.setModel}, matching every other length `push` takes.
422
+ * A model that scales scales the outline with the text, which is what a caller rotating a label
423
+ * wants.
424
+ *
425
+ * IT PERSISTS, exactly like {@link HbGpuRenderer.setModel} and {@link HbGpuRenderer.setColor} —
426
+ * `begin` does not reset it. The failure that buys is worth stating: a caller that sets a spread
427
+ * for one run and does not clear it draws every LATER run fat, which looks like a font-weight bug
428
+ * rather than a missing call. Per-run callers should set it per run, which is what
429
+ * `packages/canvas`'s glyph pass does.
430
+ *
431
+ * A NON-ZERO SPREAD ALSO TURNS STEM DARKENING OFF for that run, whatever
432
+ * {@link HbGpuContrast.stemDarkening} says — Godot's outline carries no contrast curve, and one
433
+ * on a dilated rim reads as a halo. The reason is under that field; the pair "outline then fill"
434
+ * above is unaffected, because the fill run is the one that keeps the correction.
435
+ *
436
+ * COST. Bounded, but not free: a fragment that is neither solid ink nor near any evaluates up to
437
+ * 65 coverage taps (and five times that below ppem 16), and the quad it does so over grows by
438
+ * `spread` on every side. Beyond roughly 4 device pixels of radius the tap ceiling is reached —
439
+ * {@link HB_GPU_SPREAD_MAX_TAPS} — and the dilated rim starts to scallop: measured against a Godot
440
+ * 4.5.1 golden at radius 12, the 50% contour wobbles 0.17 px where the engine's wobbles 0.11. The
441
+ * cost does not grow past that point; only the scallop does. Negative and non-finite values are
442
+ * clamped to 0 rather than reported: this is a per-run hot-path setter with no error channel.
443
+ */
444
+ setSpread(px: number): void;
445
+ /**
446
+ * Re-state both sizes after the embedder resized its stage or its drawing buffer.
447
+ *
448
+ * Does NOT call `gl.viewport` — this renderer never touches it. It updates the design->clip
449
+ * projection (from the DESIGN pair) and the `u_viewport` the dilation is measured in (from the
450
+ * FRAMEBUFFER pair), both of which must describe the viewport the embedder will have set by the
451
+ * time `end` runs. The framebuffer pair defaults to the design pair, which is right only at a
452
+ * device-pixel ratio of 1 — see {@link HbGpuRendererOptions.framebufferWidth}.
453
+ */
454
+ setViewport(designWidth: number, designHeight: number, framebufferWidth?: number, framebufferHeight?: number): void;
455
+ /**
456
+ * Submit. Returns what the frame cost.
457
+ *
458
+ * WHAT THIS DOES NOT DO, because the context is borrowed: it does not set `gl.viewport` and it
459
+ * does not clear. Both were here while this file owned a stage, and both are actively wrong in a
460
+ * shared context — a clear inside a glyph pass erases everything the embedder's executor already
461
+ * drew, and a viewport call silently overrides a scissored or letterboxed pass.
462
+ *
463
+ * WHAT IT LEAVES DIRTY, exhaustively, so an embedder's restore code can be written against it.
464
+ * On a frame that drew anything (`instances > 0`):
465
+ *
466
+ * - `BLEND` is ENABLED, `blendEquation` is `FUNC_ADD`, `blendFunc` is
467
+ * `(ONE, ONE_MINUS_SRC_ALPHA)` — premultiplied MIX. Note `blendFunc`/`blendEquation`, not
468
+ * the `*Separate` forms, so both the RGB and the alpha halves are set.
469
+ * - The current program is this renderer's.
470
+ * - `ARRAY_BUFFER` is bound to the instance buffer.
471
+ * - `ACTIVE_TEXTURE` is `TEXTURE0` and `TEXTURE_BINDING_2D` on unit 0 is the atlas.
472
+ * - `VERTEX_ARRAY_BINDING` is null (unbound, not restored to whatever was bound before —
473
+ * WebGL2 has no cheap way to read it back).
474
+ * - Uniforms of this renderer's program only — `u_viewProjection`, `u_viewport`, `u_gamma`,
475
+ * `u_stemDarken`, `hb_gpu_atlas` and `hb_gpu_atlas_width`. Per-run model, colour and spread
476
+ * are captured into each instance record; their setters still persist until replaced and
477
+ * `begin` deliberately does not reset them (see {@link HbGpuRenderer.setSpread}).
478
+ *
479
+ * Untouched: viewport, scissor box and `SCISSOR_TEST`, clear colour, depth/stencil state,
480
+ * framebuffer bindings, the three unpack flags — `UNPACK_ALIGNMENT`, `UNPACK_FLIP_Y_WEBGL` and
481
+ * `UNPACK_PREMULTIPLY_ALPHA_WEBGL`, all saved and restored around every upload — and every
482
+ * texture unit but 0.
483
+ *
484
+ * On an empty frame it returns immediately and leaves ALL of the above untouched too, which is
485
+ * why an embedder must restore unconditionally rather than only when `instances > 0`.
486
+ */
487
+ end(): {
488
+ instances: number;
489
+ drawCalls: number;
490
+ };
491
+ /**
492
+ * The context is gone: drop every GL handle WITHOUT calling into GL to free it.
493
+ *
494
+ * Call this from `webglcontextlost` (`packages/canvas/src/present.ts` offers exactly that hook,
495
+ * and `preventDefault` on that event is what makes a restore possible at all). The allocation
496
+ * table survives — offsets, faces and glyph ids — because {@link rebuild} puts the same texels
497
+ * back at the same offsets, which is what keeps every {@link GlyphSlot} the embedder is holding
498
+ * valid across the loss.
499
+ */
500
+ notifyContextLost(): void;
501
+ /**
502
+ * The context is back: recreate the program, the buffers, the VAO and the texture, then re-encode
503
+ * and re-upload every resident glyph at the offset it already had.
504
+ *
505
+ * SAME OFFSETS, DELIBERATELY. Repacking would be simpler and would invalidate every slot the
506
+ * embedder holds — which the generation guard would then turn into a silently empty frame rather
507
+ * than garbage, but empty is still wrong. Re-materialising the atlas byte for byte means a
508
+ * restore needs no cooperation from the caller beyond this one call.
509
+ *
510
+ * Returns `false` (and reports) if the GL objects could not be rebuilt. A face whose font has
511
+ * been destroyed, or a glyph that no longer encodes to the same length, loses its allocation:
512
+ * those slots then fail the generation check and are skipped rather than drawn wrong.
513
+ */
514
+ rebuild(): boolean;
515
+ readonly atlas: AtlasStats;
516
+ readonly blobs: BlobStats;
517
+ /** Delete this renderer's GL objects. Does NOT touch the context or the canvas. */
518
+ dispose(): void;
519
+ }
520
+ declare function createHbGpuRenderer(module: HbGpu, options: HbGpuRendererOptions): HbGpuRenderer | null;
521
+ //#endregion
522
+ export { ATLAS_WIDTH, AtlasStats, BlobStats, FRAGMENT_MAIN, GlyphSlot, HB_GPU_CONTRAST_DEFAULT, HB_GPU_CONTRAST_NONE, HB_GPU_SPREAD_MAX_TAPS, HbGpuContrast, HbGpuFace, HbGpuRenderer, HbGpuRendererOptions, createHbGpuRenderer };
523
+ //# sourceMappingURL=webgl.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webgl.d.ts","names":[],"sources":["../src/webgl.ts"],"mappings":";;;;;AA+EA;;;;AAAwB;AAgExB;cAhEa,WAAA;;;AAgEsB;AA6dnC;;;;AAA0B;AA4T1B;;;;;;;;AAMe;AAIf;;;;cAnyBa,sBAAA;AAAA,cA6dA,aAAA;;;;;;;;;;;AAuWL;AAGR;UA9CiB,SAAA;;WAEN,EAAA;EAmDT;EAAA,SAjDS,KAAA;EA4DT;EAAA,SA1DS,IAAA;AAAA;;UAIM,SAAA;EAoEf;;AAAU;AAGZ;EAlEE,MAAA;EACA,OAAA;EAiEwB;;;;EA5DxB,GAAA;EAqEa;AAwBf;;;;AAyCe;AAIf;;;;EA/HE,UAAA;EA4IW;EA1IX,GAAA;;EAEA,IAAA;EA2IA;EAzIA,IAAA;EACA,IAAA;EACA,IAAA;EACA,IAAA;EAgOW;EA9NX,MAAA;AAAA;AAAA,UAGe,UAAA;EA2If;;;;;;EApIA,SAAA;EA4MA;;;;;;;;EAnMA,gBAAA;EA4Ne;EA1Nf,OAAA;EACA,UAAA;EACA,cAAA;EA+PmB;EA7PnB,SAAA;EAyQQ;EAvQR,KAAA;EA0QG;;;;;;;EAlQH,UAAA;AAAA;AAAA,UAGe,SAAA;EA2MF;EAzMb,MAAA;EA2NS;EAzNT,UAAA;EA4OmB;;;;EAvOnB,aAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;UAwBe,aAAA;EAmQK;;;;;;;;;;;;;EArPpB,KAAA;EAsWA;;;;;;;AAIO;AA+FT;;;;;;;;;;;;;;AAGgB;;;;EAjbd,aAAa;AAAA;;cAIF,uBAAA,EAAyB,aAGpC;;;;;;;;;cAUW,oBAAA,EAAsB,aAGjC;AAAA,UAEe,oBAAA;;;;;;;;EAQf,EAAA,EAAI,sBAAA;;;;;;;;;EASJ,WAAA;EACA,YAAA;;;;;;;;;;;;;EAaA,gBAAA;EACA,iBAAA;;;;;;;;;EASA,WAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAuCA,aAAA;;;;;;;;EAQA,QAAA,GAAW,aAAA;;;;;EAKX,mBAAA;;;;;;;;;EASA,OAAA,EAAS,OAAA,EAAS,YAAA;AAAA;AAAA,UAGH,aAAA;;WAEN,EAAA,EAAI,sBAAA;;;;;;;;WAQJ,UAAA;;;;;;;;;;WAUA,WAAA;;;;;;;;;;;;;;;;;;;EAmBT,YAAA,CAAa,IAAA,EAAM,SAAA,EAAW,KAAA,YAAiB,SAAA;;;;;;;;;;;EAW/C,MAAA,CACE,IAAA,EAAM,SAAA,EACN,OAAA,UACA,KAAA,EAAO,YAAA,GACN,SAAA;;;;;;;;;;;;;;;;;;EAkBH,OAAA,CAAQ,IAAA,EAAM,SAAA,EAAW,OAAA,WAAkB,SAAA;;EAE3C,KAAA;;;;;;;;;;;;;EAaA,IAAA,CAAK,IAAA,EAAM,SAAA,EAAW,CAAA,UAAW,CAAA,UAAW,WAAA;;EAE5C,QAAA,CAAS,KAAA,EAAO,SAAA;;EAEhB,QAAA,CAAS,CAAA,UAAW,CAAA,UAAW,CAAA,UAAW,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAyC1C,SAAA,CAAU,EAAA;;;;;;;;;;EAUV,WAAA,CACE,WAAA,UACA,YAAA,UACA,gBAAA,WACA,iBAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAkCF,GAAA;IAAS,SAAA;IAAmB,SAAA;EAAA;;;;;;;;;;EAU5B,iBAAA;;;;;;;;;;;;;;EAcA,OAAA;EAAA,SACS,KAAA,EAAO,UAAA;EAAA,SACP,KAAA,EAAO,SAAA;;EAEhB,OAAA;AAAA;AAAA,iBA+Fc,mBAAA,CACd,MAAA,EAAQ,KAAA,EACR,OAAA,EAAS,oBAAA,GACR,aAAA"}