/** * A texture atlas of rasterized glyphs, for grids that draw the *same small set * of glyphs* thousands of times per frame. * * Named `GlyphRasterAtlas` rather than `GlyphAtlas` because `@vectojs/layout` * already exports a `GlyphAtlas` interface — a map of grapheme to *vector* path * metrics — and the core barrel re-exports that package, so the shorter name is a * hard collision. The distinction is also worth keeping: that one holds path data * for measuring, this one holds pixels for blitting. * * ## Why this exists alongside {@link TextRasterCache} * * Both replace per-cell `fillText` with a bitmap blit. The difference is where * the pixels live, and measurement says that difference decides whether the idea * works at all. * * `TextRasterCache` allocates **one canvas per cached run**. A warm cache for a * syntax-highlighted code grid holds a few hundred of them (glyphs x theme * colours), so a frame blits from a few hundred distinct source textures and the * GPU re-binds on nearly every call. Measured on real hardware, that per-source * cost is invisible at 2k cells and dominant at 40k: Chrome went 1.82x at 2k to * **0.87x at 40k** — slower than the `fillText` it replaced. Its per-call cost * grows with cell count (1.22 -> 2.89 us) rather than staying flat. * * This atlas keeps every glyph in **one** canvas and selects with a source rect, * so the source texture never changes. Same call count, same pixels, same * geometry — and per-call cost is flat as the grid grows (Chrome 1.10 -> 1.11 * us), giving **1.90-2.27x over `fillText` on both engines at every size**. * Full data: `vectojs-docs/forge/baselines/raster-cache-findings.md`. * * The win comes from *reuse*, so this is for bounded glyph sets: a monospace code * grid, a terminal, a data grid, a numeric HUD. Prose is the wrong customer — * every run is distinct, so an atlas is pure overhead (use `RichText`'s coalesced * runs there instead). * * ## Requires a source-rect blit * * Selecting one slot needs {@link IRenderer.drawImageRect}, which is optional: * `CanvasRenderer` implements it, `SVGRenderer` deliberately does not (an SVG * blit embeds its source as a data URL, so a per-cell sub-rect would inline the * whole atlas thousands of times — and vector text is the correct output for a * vector export anyway). Callers must keep their `fillText` path for renderers * that lack it: * * ```ts * const slot = atlas.get(font, color, glyph); * if (slot && r.drawImageRect) { * r.drawImageRect(atlas.source, slot.sx, slot.sy, slot.sw, slot.sh, * x - slot.offsetX, baselineY - slot.offsetY, slot.w, slot.h); * } else { * r.fillText(glyph, x, baselineY, font, color); * } * ``` */ /** Where one glyph lives in the atlas, and how to blit it at a baseline. */ export interface GlyphSlot { /** Source X in atlas *device* pixels. */ sx: number; /** Source Y in atlas *device* pixels. */ sy: number; /** Source width in atlas *device* pixels. */ sw: number; /** Source height in atlas *device* pixels. */ sh: number; /** Destination width in CSS pixels. */ w: number; /** Destination height in CSS pixels. */ h: number; /** Left inset (CSS px) of the glyph origin inside the slot. */ offsetX: number; /** Distance (CSS px) from the slot top down to the text baseline. */ offsetY: number; /** The cluster these pixels represent. */ glyph: string; /** The CSS font shorthand these pixels were rasterized with. */ font: string; /** Advance width (CSS px) of the cluster, i.e. `measureText().width`. */ advance: number; /** * Ink extent left of the glyph origin (CSS px), from `actualBoundingBoxLeft`. * * Carried on the slot so a blit can be mapped back to the same geometry a * `fillText` would have produced. Without it, instrumentation that traces draw * calls to verify grid positioning (`e2e/text-projection.e2e.ts`) can see only * a destination rect and cannot recover where the glyph origin sat inside it. */ left: number; /** Ink extent right of the glyph origin (CSS px), from `actualBoundingBoxRight`. */ right: number; } /** Instrumentation counters, e.g. to surface a HUD hit rate. */ export interface GlyphRasterAtlasStats { /** Requests served from an existing slot. */ hits: number; /** Requests that had to rasterize. */ misses: number; /** Glyphs currently resident. */ size: number; /** * Times the atlas filled up and was reset. * * Steady-state thrash means the glyph set is unbounded for the configured * size, and the atlas is doing net harm — every reset re-rasterizes everything. * A caller that watches this can fall back to `fillText` permanently. */ resets: number; } /** Options for {@link GlyphRasterAtlas}. */ export interface GlyphRasterAtlasOptions { /** * Device-pixel-ratio to rasterize at. Slots record device pixels while `w`/`h` * stay in CSS pixels, so the blit is DPR-correct without caller arithmetic. * Default `1`. */ dpr?: number; /** * Max atlas edge in device pixels, capped at 8192 — comfortably inside the * lowest common `maxTextureSize` while leaving room for thousands of glyphs. * Exceeding a browser's real limit yields a silently blank canvas, so this is * clamped rather than trusted. Default `2048`. */ maxSize?: number; } /** * A glyph atlas. Create one per renderer/scene — instances share no state, so * multiple scenes or an SSR pass never collide. */ export declare class GlyphRasterAtlas { private readonly slots; private readonly dpr; private readonly maxSize; private canvas; private ctx; /** * Shelf packing: glyphs land left-to-right on a row, then a new row starts. * A monospace grid produces near-uniform widths, so shelves waste very little * and cost one comparison per insert — a real 2D packer would buy nothing here. */ private penX; private penY; private rowHeight; private _hits; private _misses; private _resets; constructor(options?: GlyphRasterAtlasOptions); /** * The device-pixel-ratio these slots were rasterized at. * * Immutable by design. Slot `sx`/`sy`/`sw`/`sh` are device pixels at *this* * ratio while `w`/`h` are CSS pixels, so changing it would invalidate every * resident slot — an atlas is therefore keyed by DPR and replaced on a change, * not mutated (see `Markdown`'s code atlas pool). Exposed so a caller can * compare it against {@link IRenderer.pixelRatio} and assert the blit is * 1:1 rather than resampled: `blitScale = renderer.pixelRatio / atlas.dpr`, * which must be 1 for the pixels to land crisp. */ get pixelRatio(): number; /** Live instrumentation snapshot. */ get stats(): GlyphRasterAtlasStats; /** * The atlas canvas, to pass as the blit source. * * `null` until the first successful {@link get}, and in any non-DOM context. */ get source(): HTMLCanvasElement | null; private ensureCanvas; /** * Look up a glyph, rasterizing it into the atlas on first request. * * @param font - Full CSS `font` shorthand, used for measuring and painting. * @param color - CSS color baked into the pixels. * @param glyph - A single grapheme cluster. Long strings are rejected * (`null`): they defeat the atlas's fixed-slot packing and belong in * `fillText` or {@link TextRasterCache}. * @returns The slot, or `null` when the caller must fall back to `fillText` * (headless, unrasterizable, or too large to pack). */ get(font: string, color: string, glyph: string): GlyphSlot | null; /** * Find the slot occupying a source position, or `null`. * * The inverse of {@link get}: it maps a blit back to the glyph it drew. Exists * for instrumentation — a test or devtool that traces `drawImage` calls sees * only a source rect, and needs this to recover which cluster was painted and * with what metrics. Linear over resident slots, so it is a diagnostic, not a * per-frame call. */ slotAt(sx: number, sy: number): GlyphSlot | null; /** * Drop every glyph and reuse the canvas. * * Call after a font or theme change: slots are keyed by `(font, color, glyph)` * so stale entries are never *returned* wrongly, but they do occupy space. */ reset(): void; /** Release the backing canvas and all slots. */ destroy(): void; }