From ac45b0da6415c1a5665e61d414da276f2aa829f0 Mon Sep 17 00:00:00 2001 From: Evan Pelle Date: Mon, 15 Jun 2026 03:38:44 +0000 Subject: [PATCH] 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 --- .../render/gl/passes/name-pass/ArialAtlas.ts | 85 +++++++++---------- .../render/gl/passes/name-pass/TextProgram.ts | 11 ++- .../render/gl/passes/name-pass/index.ts | 1 + .../render/gl/shaders/name/name.vert.glsl | 6 +- 4 files changed, 54 insertions(+), 49 deletions(-) diff --git a/src/client/render/gl/passes/name-pass/ArialAtlas.ts b/src/client/render/gl/passes/name-pass/ArialAtlas.ts index 1be48ff64..8ae544a4a 100644 --- a/src/client/render/gl/passes/name-pass/ArialAtlas.ts +++ b/src/client/render/gl/passes/name-pass/ArialAtlas.ts @@ -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 { 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, diff --git a/src/client/render/gl/passes/name-pass/TextProgram.ts b/src/client/render/gl/passes/name-pass/TextProgram.ts index e75671841..e38978da0 100644 --- a/src/client/render/gl/passes/name-pass/TextProgram.ts +++ b/src/client/render/gl/passes/name-pass/TextProgram.ts @@ -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 { diff --git a/src/client/render/gl/passes/name-pass/index.ts b/src/client/render/gl/passes/name-pass/index.ts index 358811939..dcd70c896 100644 --- a/src/client/render/gl/passes/name-pass/index.ts +++ b/src/client/render/gl/passes/name-pass/index.ts @@ -227,6 +227,7 @@ export class NamePass { this.arialMetricsTex, arial.atlas.scaleW, arial.atlas.scaleH, + arial.renderScale, ); this.classicFont = settings.name.classicFont; diff --git a/src/client/render/gl/shaders/name/name.vert.glsl b/src/client/render/gl/shaders/name/name.vert.glsl index de3322d65..9d208de40 100644 --- a/src/client/render/gl/shaders/name/name.vert.glsl +++ b/src/client/render/gl/shaders/name/name.vert.glsl @@ -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)