Switch classic name font to a drop-in Arial (Arimo) MSDF atlas

Replace the runtime-generated bitmap Arial with a real MSDF atlas in the exact overpass format (resources/atlases/arial-atlas.{png,json}), generated from Arimo (Apache-licensed, Arial-metric clone). NamePass loads both MSDF atlases and switches per draw using each atlas's own metrics (em size, baseline, atlas dims, distance range); the flag/status/debug passes follow via setFont. Crisp at any zoom — no blur, wobble, or thinning hacks.

Removes the runtime bitmap generator, the uClassic shader branch, and the supersample/erosion code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Evan Pelle
2026-06-15 04:36:30 +00:00
parent ac45b0da64
commit e2aa55ac4f
13 changed files with 3225 additions and 380 deletions
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

+7 -3
View File
@@ -71,6 +71,7 @@ import {
createRenderSettings,
deepAssign,
MapRenderer,
preloadArialAtlasData,
preloadAtlasData,
type RenderSettings,
} from "./render/gl";
@@ -454,9 +455,12 @@ async function createClientGame(
mapLoader,
);
}
// Kick off the font-atlas fetch so it overlaps with worker init; the
// render passes need it parsed before createWebGLView runs.
const atlasDataLoad = preloadAtlasData();
// Kick off the font-atlas fetches so they overlap with worker init; the
// render passes need them parsed before createWebGLView runs.
const atlasDataLoad = Promise.all([
preloadAtlasData(),
preloadArialAtlasData(),
]);
const worker = new WorkerClient(lobbyConfig.gameStartInfo, clientID);
await worker.initialize();
await atlasDataLoad;
+4 -1
View File
@@ -4,7 +4,10 @@ export type { AttackRingInput } from "../types";
export { GraphicsOverridesSchema } from "./GraphicsOverrides";
export type { GraphicsOverrides } from "./GraphicsOverrides";
export { MapRenderer } from "./MapRenderer";
export { preloadAtlasData } from "./passes/name-pass/AtlasData";
export {
preloadArialAtlasData,
preloadAtlasData,
} from "./passes/name-pass/AtlasData";
export type { SpawnCenter } from "./passes/SpawnOverlayPass";
export { applyGraphicsOverrides } from "./RenderOverrides";
export { createRenderSettings, dumpSettings } from "./RenderSettings";
@@ -1,231 +0,0 @@
/**
* Runtime Arial bitmap-font atlas generator.
*
* The "classic" name font is Arial rendered to a coverage atlas (white glyphs
* on transparent), as an alternative to the overpass-bold MSDF atlas. There's
* no Arial asset to ship and no offline bmfont tooling, so the atlas is built
* 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.
*
* 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";
// 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 lighter than the MSDF bold).
const FONT = `100 ${RENDER_PX}px Arial, "Liberation Sans", sans-serif`;
// Measuring cell + pen, in render (supersampled) pixels.
const CELL = 160;
const PEN_X = 16;
const PEN_Y = 118;
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.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.
function* codepoints(): Generator<number> {
for (let c = 32; c <= 383; c++) {
if (c >= 127 && c <= 159) continue;
yield c;
}
}
interface Measured {
code: number;
ch: string;
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 tmp = document.createElement("canvas");
tmp.width = CELL;
tmp.height = CELL;
const tctx = tmp.getContext("2d", { willReadFrequently: true })!;
tctx.font = FONT;
tctx.textBaseline = "alphabetic";
tctx.textAlign = "left";
tctx.fillStyle = "#fff";
const measured: Measured[] = [];
for (const code of codepoints()) {
const ch = String.fromCodePoint(code);
const advance = Math.ceil(tctx.measureText(ch).width);
tctx.clearRect(0, 0, CELL, CELL);
tctx.fillText(ch, PEN_X, PEN_Y);
const data = tctx.getImageData(0, 0, CELL, CELL).data;
let minX = CELL;
let minY = CELL;
let maxX = -1;
let maxY = -1;
for (let y = 0; y < CELL; y++) {
for (let x = 0; x < CELL; x++) {
if (data[(y * CELL + x) * 4 + 3] > 8) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
if (maxX < 0) {
// No ink (e.g. space) — advance only, zero-size glyph.
measured.push({ code, ch, advance, w: 0, h: 0, srcMinX: 0, srcMinY: 0 });
continue;
}
measured.push({
code,
ch,
advance,
w: maxX - minX + 1,
h: maxY - minY + 1,
srcMinX: minX,
srcMinY: minY,
});
}
// --- shelf packing into a fixed-width atlas (render pixels) ---
const chars: BMChar[] = [];
const placements: { m: Measured; ax: number; ay: number }[] = [];
let x = PAD;
let y = PAD;
let rowH = 0;
for (const m of measured) {
if (m.w === 0) {
chars.push(toBMChar(m, 0, 0));
continue;
}
if (x + m.w + PAD > ATLAS_W) {
x = PAD;
y += rowH + PAD;
rowH = 0;
}
placements.push({ m, ax: x, ay: y });
chars.push(toBMChar(m, x, y));
x += m.w + PAD;
if (m.h > rowH) rowH = m.h;
}
const ATLAS_H = y + rowH + PAD;
// --- render pass: draw each glyph at its packed slot ---
const canvas = document.createElement("canvas");
canvas.width = ATLAS_W;
canvas.height = ATLAS_H;
const actx = canvas.getContext("2d")!;
actx.font = FONT;
actx.textBaseline = "alphabetic";
actx.textAlign = "left";
actx.fillStyle = "#fff";
for (const { m, ax, ay } of placements) {
// Drawing at pen P put the bbox at (P + (srcMin - PEN)); solve for the pen
// that lands the tight top-left at (ax, ay).
const penX = ax - m.srcMinX + PEN_X;
const penY = ay - m.srcMinY + PEN_Y;
actx.fillText(m.ch, penX, penY);
}
erodeCoverage(actx, ATLAS_W, ATLAS_H);
const atlas: ParsedAtlas = {
fontSize: EM,
base: BASE,
scaleW: ATLAS_W,
scaleH: ATLAS_H,
distanceRange: 0,
chars,
kernings: [],
};
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
* render pixels.
*/
function erodeCoverage(
ctx: CanvasRenderingContext2D,
w: number,
h: number,
): void {
if (ERODE_PX <= 0 || ERODE_STRENGTH <= 0) return;
const img = ctx.getImageData(0, 0, w, h);
const a = img.data;
const src = new Uint8ClampedArray(a.length);
src.set(a);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4 + 3;
const cur = src[i];
if (cur === 0) continue;
let min = cur;
for (let dy = -ERODE_PX; dy <= ERODE_PX && min > 0; dy++) {
const yy = y + dy;
if (yy < 0 || yy >= h) {
min = 0;
break;
}
for (let dx = -ERODE_PX; dx <= ERODE_PX; dx++) {
const xx = x + dx;
if (xx < 0 || xx >= w) {
min = 0;
break;
}
const v = src[(yy * w + xx) * 4 + 3];
if (v < min) min = v;
}
}
a[i] = Math.round(cur + (min - cur) * ERODE_STRENGTH);
}
}
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 / 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,
};
}
@@ -21,39 +21,49 @@ interface RawMsdfAtlas {
}
// Fetched at game-load time rather than statically imported — the JSON is
// ~320 KB minified and would otherwise sit in the main bundle.
let atlasData: RawMsdfAtlas | null = null;
let atlasDataPromise: Promise<void> | null = null;
// large (~320 KB for overpass) and would otherwise sit in the main bundle.
const atlasData: Record<string, RawMsdfAtlas> = {};
const atlasPromises: Record<string, Promise<void>> = {};
export function preloadAtlasData(): Promise<void> {
atlasDataPromise ??= fetch(assetUrl("atlases/msdf-atlas.json"))
function preload(file: string): Promise<void> {
atlasPromises[file] ??= fetch(assetUrl(`atlases/${file}`))
.then((response) => {
if (!response.ok) {
throw new Error(`Failed to fetch msdf-atlas.json: ${response.status}`);
throw new Error(`Failed to fetch ${file}: ${response.status}`);
}
return response.json();
})
.then((json) => {
atlasData = json as RawMsdfAtlas;
atlasData[file] = json as RawMsdfAtlas;
});
return atlasDataPromise;
return atlasPromises[file];
}
export function parseAtlasData(): ParsedAtlas {
if (atlasData === null) {
throw new Error("Atlas data not loaded; await preloadAtlasData() first");
function parse(file: string): ParsedAtlas {
const raw = atlasData[file];
if (raw === undefined) {
throw new Error(`Atlas ${file} not loaded; await its preload first`);
}
return {
fontSize: atlasData.info.size,
base: atlasData.common.base,
scaleW: atlasData.common.scaleW,
scaleH: atlasData.common.scaleH,
distanceRange: atlasData.distanceField?.distanceRange ?? 4,
chars: atlasData.chars,
kernings: atlasData.kernings ?? [],
fontSize: raw.info.size,
base: raw.common.base,
scaleW: raw.common.scaleW,
scaleH: raw.common.scaleH,
distanceRange: raw.distanceField?.distanceRange ?? 4,
chars: raw.chars,
kernings: raw.kernings ?? [],
};
}
/** Default MSDF font (overpass-bold) — names + troops + structure levels. */
export const preloadAtlasData = (): Promise<void> => preload("msdf-atlas.json");
export const parseAtlasData = (): ParsedAtlas => parse("msdf-atlas.json");
/** Classic name font: Arimo MSDF atlas (Arial-metric), same format as overpass. */
export const preloadArialAtlasData = (): Promise<void> =>
preload("arial-atlas.json");
export const parseArialAtlasData = (): ParsedAtlas => parse("arial-atlas.json");
// ---------------------------------------------------------------------------
// CPU-side glyph lookup tables
// ---------------------------------------------------------------------------
@@ -69,6 +69,14 @@ export class DebugProgram {
this.uNameScaleCap = gl.getUniformLocation(this.program, "uNameScaleCap")!;
}
/** Update font metrics used to position debug boxes (on a font toggle). */
setFont(fontSize: number, base: number): void {
const gl = this.gl;
gl.useProgram(this.program);
gl.uniform1f(gl.getUniformLocation(this.program, "uFontSize")!, fontSize);
gl.uniform1f(gl.getUniformLocation(this.program, "uFontBase")!, base);
}
draw(
cameraMatrix: Float32Array,
settings: RenderSettings,
@@ -122,6 +122,14 @@ export class IconProgram {
return this.emojiReady;
}
/** Update font metrics used to size/position icons (on a font toggle). */
setFont(fontSize: number, base: number): void {
const gl = this.gl;
gl.useProgram(this.program);
gl.uniform1f(gl.getUniformLocation(this.program, "uFontSize")!, fontSize);
gl.uniform1f(gl.getUniformLocation(this.program, "uFontBase")!, base);
}
private loadEmojiAtlas(): void {
const gl = this.gl;
const img = new Image();
@@ -138,6 +138,14 @@ export class StatusIconProgram {
img.src = statusAtlasUrl;
}
/** Update font metrics used to position status icons (on a font toggle). */
setFont(fontSize: number, base: number): void {
const gl = this.gl;
gl.useProgram(this.program);
gl.uniform1f(gl.getUniformLocation(this.program, "uFontSize")!, fontSize);
gl.uniform1f(gl.getUniformLocation(this.program, "uFontBase")!, base);
}
draw(
cameraMatrix: Float32Array,
settings: RenderSettings,
@@ -1,16 +1,17 @@
/**
* TextProgram — text rendering for player names + troop counts.
* TextProgram — MSDF text rendering (player names + troop counts).
*
* Supports two fonts, switchable per draw via settings.name.classicFont:
* - MSDF: the overpass-bold atlas (default), loaded async from CDN.
* - classic: an Arial bitmap coverage atlas built at runtime (set via
* setArialFont), drawn fill-only (no synthesized outline).
* Both fonts share the em size (48) and baseline (36), so name sizing and the
* sibling icon/status passes are unaffected by the choice.
* Supports two MSDF fonts, switchable per draw via settings.name.classicFont:
* - default: the overpass-bold atlas.
* - classic: the Arial (Arimo) atlas — same MSDF format, installed via
* setArialFont.
* Each font carries its own metrics (em size, baseline, atlas dimensions,
* distance range); the active set is pushed to the shader per draw. The sibling
* icon/status passes are kept aligned by NamePass via their own setFont().
*
* Owns: shader program, uniform locations, the MSDF atlas texture and the
* Arial atlas texture. Glyph-metric / cursor / string / player-data textures
* are passed in and bound at draw time but not owned here.
* Owns: shader program, uniform locations, both atlas textures. Glyph-metric /
* cursor / string / player-data textures are passed in and bound at draw time
* but not owned here.
*/
import { assetUrl } from "src/core/AssetUrls";
@@ -22,6 +23,7 @@ import type { ParsedAtlas } from "./Types";
import { LINES_PER_PLAYER, MAX_CHARS } from "./Types";
const atlasUrl = assetUrl("atlases/msdf-atlas.png");
const arialAtlasUrl = assetUrl("atlases/arial-atlas.png");
export interface TextProgramTextures {
glyphMetrics: WebGLTexture;
@@ -30,31 +32,33 @@ export interface TextProgramTextures {
playerData: WebGLTexture;
}
/** Per-font GPU resources + metrics. */
interface FontGpu {
atlasTex: WebGLTexture | null;
metricsTex: WebGLTexture | null;
fontSize: number;
base: number;
scaleW: number;
scaleH: number;
distanceRange: number;
}
export class TextProgram {
private gl: WebGL2RenderingContext;
private program: WebGLProgram;
private textures: TextProgramTextures;
// MSDF atlas (async-loaded)
private atlasTex: WebGLTexture | null = null;
private atlasReady = false;
private msdfScaleW: number;
private msdfScaleH: number;
private distanceRange: number;
// Arial bitmap atlas (built at runtime, set via setArialFont)
private arialAtlasTex: WebGLTexture | null = null;
private arialMetricsTex: WebGLTexture | null = null;
private arialScaleW = 0;
private arialScaleH = 0;
private msdf: FontGpu;
private arial: FontGpu;
// Uniform locations
private uCamera: WebGLUniformLocation;
private uTime: WebGLUniformLocation;
private uDistRange: WebGLUniformLocation;
private uFontSize: WebGLUniformLocation;
private uBase: WebGLUniformLocation;
private uAtlasScaleW: WebGLUniformLocation;
private uAtlasScaleH: WebGLUniformLocation;
private uClassic: WebGLUniformLocation;
private uDistRange: WebGLUniformLocation;
private uLerpSpeed: WebGLUniformLocation;
private uCullThreshold: WebGLUniformLocation;
private uNameScaleFactor: WebGLUniformLocation;
@@ -78,9 +82,25 @@ export class TextProgram {
) {
this.gl = gl;
this.textures = textures;
this.distanceRange = atlas.distanceRange;
this.msdfScaleW = atlas.scaleW;
this.msdfScaleH = atlas.scaleH;
this.msdf = {
atlasTex: null,
metricsTex: textures.glyphMetrics,
fontSize: atlas.fontSize,
base: atlas.base,
scaleW: atlas.scaleW,
scaleH: atlas.scaleH,
distanceRange: atlas.distanceRange,
};
this.arial = {
atlasTex: null,
metricsTex: null,
fontSize: atlas.fontSize,
base: atlas.base,
scaleW: atlas.scaleW,
scaleH: atlas.scaleH,
distanceRange: atlas.distanceRange,
};
this.program = createProgram(
gl,
@@ -96,20 +116,14 @@ export class TextProgram {
gl.uniform1i(gl.getUniformLocation(this.program, "uStrings"), 3);
gl.uniform1i(gl.getUniformLocation(this.program, "uPlayerData"), 4);
// Static uniforms — em size + baseline are shared by both fonts.
gl.uniform1f(
gl.getUniformLocation(this.program, "uFontSize")!,
atlas.fontSize,
);
gl.uniform1f(gl.getUniformLocation(this.program, "uBase")!, atlas.base);
// Dynamic uniform locations
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uTime = gl.getUniformLocation(this.program, "uTime")!;
this.uDistRange = gl.getUniformLocation(this.program, "uDistRange")!;
this.uFontSize = gl.getUniformLocation(this.program, "uFontSize")!;
this.uBase = gl.getUniformLocation(this.program, "uBase")!;
this.uAtlasScaleW = gl.getUniformLocation(this.program, "uAtlasScaleW")!;
this.uAtlasScaleH = gl.getUniformLocation(this.program, "uAtlasScaleH")!;
this.uClassic = gl.getUniformLocation(this.program, "uClassic")!;
this.uDistRange = gl.getUniformLocation(this.program, "uDistRange")!;
this.uLerpSpeed = gl.getUniformLocation(this.program, "uLerpSpeed")!;
this.uCullThreshold = gl.getUniformLocation(
this.program,
@@ -153,41 +167,26 @@ export class TextProgram {
"uHoverGlowAlpha",
)!;
this.loadAtlas();
this.loadAtlas(atlasUrl, this.msdf);
}
/** True when the atlas for the requested font is uploaded and drawable. */
isReady(classic: boolean): boolean {
return classic ? this.arialAtlasTex !== null : this.atlasReady;
return (classic ? this.arial.atlasTex : this.msdf.atlasTex) !== null;
}
/**
* 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()!;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
this.arialAtlasTex = tex;
this.arialMetricsTex = metricsTex;
this.arialScaleW = scaleW / renderScale;
this.arialScaleH = scaleH / renderScale;
/** Install the classic (Arial/Arimo) MSDF atlas: metrics + async-loaded image. */
setArialFont(metricsTex: WebGLTexture, atlas: ParsedAtlas): void {
this.arial.metricsTex = metricsTex;
this.arial.fontSize = atlas.fontSize;
this.arial.base = atlas.base;
this.arial.scaleW = atlas.scaleW;
this.arial.scaleH = atlas.scaleH;
this.arial.distanceRange = atlas.distanceRange;
this.loadAtlas(arialAtlasUrl, this.arial);
}
private loadAtlas(): void {
private loadAtlas(url: string, font: FontGpu): void {
const gl = this.gl;
const img = new Image();
img.crossOrigin = "anonymous";
@@ -199,10 +198,9 @@ export class TextProgram {
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
this.atlasTex = tex;
this.atlasReady = true;
font.atlasTex = tex;
};
img.src = atlasUrl;
img.src = url;
}
draw(
@@ -219,22 +217,16 @@ export class TextProgram {
const gl = this.gl;
const ns = settings.name;
const font = classic ? this.arial : this.msdf;
gl.useProgram(this.program);
const atlasTex = classic ? this.arialAtlasTex! : this.atlasTex!;
const metricsTex = classic
? this.arialMetricsTex!
: this.textures.glyphMetrics;
const scaleW = classic ? this.arialScaleW : this.msdfScaleW;
const scaleH = classic ? this.arialScaleH : this.msdfScaleH;
const distRange = classic ? 0 : this.distanceRange;
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform1f(this.uTime, performance.now() / 1000);
gl.uniform1f(this.uDistRange, distRange);
gl.uniform1f(this.uAtlasScaleW, scaleW);
gl.uniform1f(this.uAtlasScaleH, scaleH);
gl.uniform1i(this.uClassic, classic ? 1 : 0);
gl.uniform1f(this.uFontSize, font.fontSize);
gl.uniform1f(this.uBase, font.base);
gl.uniform1f(this.uAtlasScaleW, font.scaleW);
gl.uniform1f(this.uAtlasScaleH, font.scaleH);
gl.uniform1f(this.uDistRange, font.distanceRange);
gl.uniform1f(this.uLerpSpeed, ns.lerpSpeed);
gl.uniform1f(this.uCullThreshold, ns.cullThreshold);
gl.uniform1f(this.uNameScaleFactor, ns.nameScaleFactor);
@@ -255,9 +247,9 @@ export class TextProgram {
gl.uniform1f(this.uHoverGlowAlpha, ns.hoverGlowAlpha);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, atlasTex);
gl.bindTexture(gl.TEXTURE_2D, font.atlasTex);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, metricsTex);
gl.bindTexture(gl.TEXTURE_2D, font.metricsTex);
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, this.textures.cursor);
gl.activeTexture(gl.TEXTURE3);
@@ -277,7 +269,7 @@ export class TextProgram {
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
if (this.atlasTex) gl.deleteTexture(this.atlasTex);
if (this.arialAtlasTex) gl.deleteTexture(this.arialAtlasTex);
if (this.msdf.atlasTex) gl.deleteTexture(this.msdf.atlasTex);
if (this.arial.atlasTex) gl.deleteTexture(this.arial.atlasTex);
}
}
+37 -23
View File
@@ -29,12 +29,12 @@ import type { RenderSettings } from "../../RenderSettings";
import { createFullscreenQuad } from "../../utils/GlUtils";
import { renderTroops } from "../../../../Utils";
import { generateArialBitmapAtlas } from "./ArialAtlas";
import type { GlyphTables } from "./AtlasData";
import {
buildEmojiLookup,
buildGlyphTables,
buildKernTable,
parseArialAtlasData,
parseAtlasData,
} from "./AtlasData";
import {
@@ -50,7 +50,7 @@ import { StatusIconProgram } from "./StatusIconProgram";
import { layoutString } from "./TextLayout";
import { TextProgram } from "./TextProgram";
import type { PlayerSlot } from "./Types";
import { CHAR_RANGE, LINES_PER_PLAYER, MAX_CHARS } from "./Types";
import { LINES_PER_PLAYER, MAX_CHARS } from "./Types";
// Flag quad aspect ratio — must match FLAG_CELL_W / FLAG_CELL_H in FlagAtlasArray.ts.
const FLAG_ASPECT = 128 / 85;
@@ -82,9 +82,13 @@ export class NamePass {
private fontBase: number;
private msdfGlyph: GlyphTables;
private msdfKern: Int8Array;
private msdfFontSize: number;
private msdfBase: number;
private arialGlyph: GlyphTables;
private arialKern: Int8Array;
private arialMetricsTex: WebGLTexture;
private arialFontSize: number;
private arialBase: number;
// Which font the cursor buffers are currently laid out for.
private classicFont = false;
@@ -141,6 +145,8 @@ export class NamePass {
this.kernTable = buildKernTable(atlas.kernings);
this.msdfGlyph = this.glyph;
this.msdfKern = this.kernTable;
this.msdfFontSize = atlas.fontSize;
this.msdfBase = atlas.base;
this.emojiCharToIndex = buildEmojiLookup();
// Runtime flag-image manager (TEXTURE_2D_ARRAY of player flags, fetched
@@ -216,25 +222,35 @@ export class NamePass {
this.maxPlayers,
);
// Classic Arial bitmap font: built once at runtime, same em/baseline as the
// MSDF atlas so sizing/icons are unchanged. Selected via name.classicFont.
const arial = generateArialBitmapAtlas();
this.arialGlyph = buildGlyphTables(arial.atlas.chars);
this.arialKern = new Int8Array(CHAR_RANGE * CHAR_RANGE); // no kerning
this.arialMetricsTex = buildGlyphMetricsTex(gl, arial.atlas);
this.textProgram.setArialFont(
arial.canvas,
this.arialMetricsTex,
arial.atlas.scaleW,
arial.atlas.scaleH,
arial.renderScale,
);
// Classic name font: Arial (Arimo) MSDF atlas — same format as overpass,
// its own metrics. Selected via name.classicFont.
const arialAtlas = parseArialAtlasData();
this.arialGlyph = buildGlyphTables(arialAtlas.chars);
this.arialKern = buildKernTable(arialAtlas.kernings);
this.arialMetricsTex = buildGlyphMetricsTex(gl, arialAtlas);
this.arialFontSize = arialAtlas.fontSize;
this.arialBase = arialAtlas.base;
this.textProgram.setArialFont(this.arialMetricsTex, arialAtlas);
this.classicFont = settings.name.classicFont;
if (this.classicFont) {
this.glyph = this.arialGlyph;
this.kernTable = this.arialKern;
}
// Apply the initially-selected font to the glyph tables + sibling passes.
this.classicFont = false;
this.applyFont(settings.name.classicFont);
}
/**
* Switch the active font: point the layout tables + line metrics at it and
* push its metrics to the icon/status/debug passes so they stay aligned.
* Each font carries its own em size and baseline.
*/
private applyFont(classic: boolean): void {
this.classicFont = classic;
this.glyph = classic ? this.arialGlyph : this.msdfGlyph;
this.kernTable = classic ? this.arialKern : this.msdfKern;
this.fontSize = classic ? this.arialFontSize : this.msdfFontSize;
this.fontBase = classic ? this.arialBase : this.msdfBase;
this.iconProgram.setFont(this.fontSize, this.fontBase);
this.statusIconProgram.setFont(this.fontSize, this.fontBase);
this.debugProgram.setFont(this.fontSize, this.fontBase);
}
// -------------------------------------------------------------------------
@@ -682,9 +698,7 @@ export class NamePass {
private syncFont(): void {
const classic = this.settings.name.classicFont;
if (classic === this.classicFont) return;
this.classicFont = classic;
this.glyph = classic ? this.arialGlyph : this.msdfGlyph;
this.kernTable = classic ? this.arialKern : this.msdfKern;
this.applyFont(classic);
for (const slot of this.slots.values()) {
if (slot.nameLen > 0) {
slot.nameHalfWidth = this.uploadStringRow(
@@ -10,7 +10,6 @@ uniform float uOutlineUsePlayerColor;
uniform float uFillUsePlayerColor;
uniform float uHoverGlowWidth; // px the white hover glow extends past the outline
uniform float uHoverGlowAlpha; // peak opacity of the hover glow
uniform int uClassic; // 1 = Arial bitmap coverage atlas, 0 = MSDF
in vec2 vUV;
in vec4 vPlayerColor; // player territory color (rgb) + alpha
@@ -26,27 +25,16 @@ void main() {
// Degenerate fragment — skip
if (vPlayerColor.a <= 0.0) discard;
// Compute fill color: player color, or per-type grayscale shade
// (black for human, grayer for nation/bot).
vec3 fillColor = mix(vec3(vNameShade), vPlayerColor.rgb, uFillUsePlayerColor);
// Classic Arial bitmap: the atlas is a coverage mask; tint it with the fill
// color, no outline (matching the old DOM-rendered names). Antialias the 0.5
// coverage contour in screen space (fwidth) so the edge stays ~1px sharp at
// any magnification instead of blurring when names are drawn large.
if (uClassic == 1) {
float a = texture(uAtlas, vUV).a;
float coverage = clamp((a - 0.5) / max(fwidth(a), 1e-5) + 0.5, 0.0, 1.0);
if (coverage <= 0.0) discard;
fragColor = vec4(fillColor, vPlayerColor.a * coverage);
return;
}
// Border darkens with night: t² stays dark longer, snaps toward the
// outline color late in the day cycle.
float t = 1.0 - uNightAmbient;
float borderT = t * t;
// Compute fill color: player color, or per-type grayscale shade
// (black for human, grayer for nation/bot). Applies in day and night.
vec3 defaultFill = vec3(vNameShade);
vec3 fillColor = mix(defaultFill, vPlayerColor.rgb, uFillUsePlayerColor);
vec3 msd = texture(uAtlas, vUV).rgb;
float sd = median(msd.r, msd.g, msd.b);
@@ -132,11 +132,7 @@ void main() {
float glyphH = m1.x;
float u0 = m1.y;
float v0 = m1.z;
// 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 u1 = m1.w;
float v1 = v0 + glyphH / uAtlasScaleH;
// Degenerate if glyph has no size (e.g. space)