Supersample the classic Arial atlas for straighter edges; render a bit thicker

Rasterize the Arial atlas 2x finer than the em metrics so large names have straighter, cleaner edges (the 48px atlas wobbled under the screen-space edge sharpening). The name shader now derives glyph UVs from glyph-size / atlas-pixels-per-em, so metrics stay in 48-em space and flag/icon sizing is unchanged; for the MSDF atlas this is identical to the old precomputed UV. Also lighten the coverage erosion so strokes are a bit thicker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Evan Pelle
2026-06-15 03:38:44 +00:00
parent b268d7410c
commit ac45b0da64
4 changed files with 54 additions and 49 deletions
@@ -7,30 +7,40 @@
* once at startup with canvas 2D: each glyph is rasterized, its tight bounding
* box is found by scanning pixels, and the glyphs are shelf-packed.
*
* Metrics are emitted with the SAME em size (48) and baseline (36) as the MSDF
* atlas, so name sizing, hit-testing, flag offsets and icon alignment are all
* unchanged — only the glyph shapes and advances differ. The returned shape is
* a ParsedAtlas, so it flows through buildGlyphTables / buildGlyphMetricsTex /
* layoutString exactly like the MSDF atlas.
* The atlas is SUPERSAMPLED: glyphs are rasterized RENDER_SCALE× larger than
* the em metrics so edges stay finer/straighter when names are drawn large.
* Metrics are still emitted in the MSDF em size (48) and baseline (36), so name
* sizing, hit-testing, flag offsets and icon alignment are unchanged — the
* extra atlas resolution is carried separately via the returned renderScale
* (the name shader derives UVs from glyph size / atlas-pixels-per-em).
*/
import type { BMChar, ParsedAtlas } from "./Types";
// Match the MSDF atlas em/base so all downstream sizing stays identical.
// Em metrics (match the MSDF atlas so downstream sizing is identical).
const EM = 48;
const BASE = 36;
// Supersample factor: rasterize this much finer than the em metrics.
const RENDER_SCALE = 2;
const RENDER_PX = EM * RENDER_SCALE;
// Thin Arial. Arial ships no dedicated thin face, so browsers without one fall
// back to regular weight (still much lighter than the MSDF bold).
const FONT = `100 ${EM}px Arial, "Liberation Sans", sans-serif`;
// back to regular weight (still lighter than the MSDF bold).
const FONT = `100 ${RENDER_PX}px Arial, "Liberation Sans", sans-serif`;
const ATLAS_W = 1024;
const PAD = 3; // transparent gutter between packed glyphs (> erosion radius)
// Measuring cell + pen, in render (supersampled) pixels.
const CELL = 160;
const PEN_X = 16;
const PEN_Y = 118;
// Arial has no face thinner than Regular, so thin the strokes by eroding the
// rasterized coverage. Fractional: each pixel's alpha is reduced toward the
// minimum of its neighbourhood, shaving ~ERODE_PX off every edge.
const ATLAS_W = 2048; // render pixels
const PAD = 4; // transparent gutter between packed glyphs (> erosion radius)
// Arial has no face thinner than Regular, so thin the strokes slightly by
// eroding the rasterized coverage. Fractional: each pixel's alpha is reduced
// toward the minimum of its neighbourhood (render pixels), blended by strength.
const ERODE_PX = 1;
const ERODE_STRENGTH = 0.85; // 0 = none, 1 = hard min-filter
const ERODE_STRENGTH = 0.5; // 0 = none, 1 = hard min-filter
// Codepoint coverage: ASCII + Latin-1 + Latin Extended-A (matches CHAR_RANGE),
// skipping the C0/C1 control gaps. Covers player names and troop labels.
@@ -44,24 +54,19 @@ function* codepoints(): Generator<number> {
interface Measured {
code: number;
ch: string;
advance: number;
w: number;
h: number;
xoffset: number;
yoffset: number;
// Where the tight bbox landed in the measuring canvas (to re-place it later).
srcMinX: number;
advance: number; // render px
w: number; // render px
h: number; // render px
srcMinX: number; // tight bbox top-left in the measuring cell (render px)
srcMinY: number;
}
export function generateArialBitmapAtlas(): {
atlas: ParsedAtlas;
canvas: HTMLCanvasElement;
renderScale: number;
} {
// --- measuring pass: rasterize each glyph, find its tight bbox ---
const CELL = 80; // > any 48px glyph incl. ascenders/descenders
const PEN_X = 8;
const PEN_Y = 56; // baseline inside the measuring cell
const tmp = document.createElement("canvas");
tmp.width = CELL;
tmp.height = CELL;
@@ -96,17 +101,7 @@ export function generateArialBitmapAtlas(): {
if (maxX < 0) {
// No ink (e.g. space) — advance only, zero-size glyph.
measured.push({
code,
ch,
advance,
w: 0,
h: 0,
xoffset: 0,
yoffset: 0,
srcMinX: 0,
srcMinY: 0,
});
measured.push({ code, ch, advance, w: 0, h: 0, srcMinX: 0, srcMinY: 0 });
continue;
}
@@ -116,14 +111,12 @@ export function generateArialBitmapAtlas(): {
advance,
w: maxX - minX + 1,
h: maxY - minY + 1,
xoffset: minX - PEN_X, // glyph left relative to the pen
yoffset: BASE - (PEN_Y - minY), // glyph top relative to the line top
srcMinX: minX,
srcMinY: minY,
});
}
// --- shelf packing into a fixed-width atlas ---
// --- shelf packing into a fixed-width atlas (render pixels) ---
const chars: BMChar[] = [];
const placements: { m: Measured; ax: number; ay: number }[] = [];
let x = PAD;
@@ -174,13 +167,14 @@ export function generateArialBitmapAtlas(): {
chars,
kernings: [],
};
return { atlas, canvas };
return { atlas, canvas, renderScale: RENDER_SCALE };
}
/**
* Thin the rasterized glyphs by eroding the alpha (coverage) channel: each
* pixel is pulled toward the minimum alpha in a (2·ERODE_PX+1)² window, blended
* by ERODE_STRENGTH. Shrinks every stroke edge by ~ERODE_PX·ERODE_STRENGTH px.
* by ERODE_STRENGTH. Shrinks every stroke edge by ~ERODE_PX·ERODE_STRENGTH
* render pixels.
*/
function erodeCoverage(
ctx: CanvasRenderingContext2D,
@@ -220,15 +214,16 @@ function erodeCoverage(
ctx.putImageData(img, 0, 0);
}
/** Build a glyph entry: atlas position in render px, metrics in em units. */
function toBMChar(m: Measured, ax: number, ay: number): BMChar {
return {
id: m.code,
char: m.ch,
width: m.w,
height: m.h,
xoffset: m.xoffset,
yoffset: m.yoffset,
xadvance: m.advance,
width: m.w / RENDER_SCALE,
height: m.h / RENDER_SCALE,
xoffset: (m.srcMinX - PEN_X) / RENDER_SCALE,
yoffset: BASE - (PEN_Y - m.srcMinY) / RENDER_SCALE,
xadvance: m.advance / RENDER_SCALE,
x: ax,
y: ay,
page: 0,
@@ -161,12 +161,17 @@ export class TextProgram {
return classic ? this.arialAtlasTex !== null : this.atlasReady;
}
/** Install the runtime-built Arial bitmap atlas (coverage mask) + metrics. */
/**
* Install the runtime-built Arial bitmap atlas (coverage mask) + metrics.
* The atlas is supersampled (rendered renderScale× finer than the em metrics),
* so the uAtlasScale uniforms carry atlas-pixels-per-em = realPixels/renderScale.
*/
setArialFont(
canvas: HTMLCanvasElement,
metricsTex: WebGLTexture,
scaleW: number,
scaleH: number,
renderScale: number,
): void {
const gl = this.gl;
const tex = gl.createTexture()!;
@@ -178,8 +183,8 @@ export class TextProgram {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
this.arialAtlasTex = tex;
this.arialMetricsTex = metricsTex;
this.arialScaleW = scaleW;
this.arialScaleH = scaleH;
this.arialScaleW = scaleW / renderScale;
this.arialScaleH = scaleH / renderScale;
}
private loadAtlas(): void {
@@ -227,6 +227,7 @@ export class NamePass {
this.arialMetricsTex,
arial.atlas.scaleW,
arial.atlas.scaleH,
arial.renderScale,
);
this.classicFont = settings.name.classicFont;
@@ -132,7 +132,11 @@ void main() {
float glyphH = m1.x;
float u0 = m1.y;
float v0 = m1.z;
float u1 = m1.w;
// Derive u1/v1 from the glyph size and atlas scale (rather than a precomputed
// u1) so a supersampled atlas — rendered finer than the em metrics — maps
// correctly: uAtlasScaleW/H carry the atlas-pixels-per-em. For the MSDF atlas
// (atlas px == em units) this is identical to the old precomputed u1.
float u1 = u0 + glyphW / uAtlasScaleW;
float v1 = v0 + glyphH / uAtlasScaleH;
// Degenerate if glyph has no size (e.g. space)