Load the classic Arial atlas lazily, only when the font is selected

The Arial MSDF atlas (~423KB png) is now fetched + uploaded the first time name.classicFont is enabled, rather than eagerly at game start. Default players who never switch fonts download nothing extra. On first toggle, names keep rendering in MSDF until the atlas is ready, then switch (no blank flash); TextProgram.setArialFont now resolves when the image is uploaded so NamePass can defer the switch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Evan Pelle
2026-06-15 05:02:25 +00:00
parent e2aa55ac4f
commit 02616ea9d1
4 changed files with 116 additions and 73 deletions
+4 -7
View File
@@ -71,7 +71,6 @@ import {
createRenderSettings,
deepAssign,
MapRenderer,
preloadArialAtlasData,
preloadAtlasData,
type RenderSettings,
} from "./render/gl";
@@ -455,12 +454,10 @@ async function createClientGame(
mapLoader,
);
}
// 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(),
]);
// Kick off the font-atlas fetch so it overlaps with worker init; the render
// passes need it parsed before createWebGLView runs. The classic (Arial)
// atlas is loaded lazily by NamePass the first time it's selected.
const atlasDataLoad = preloadAtlasData();
const worker = new WorkerClient(lobbyConfig.gameStartInfo, clientID);
await worker.initialize();
await atlasDataLoad;
+1 -4
View File
@@ -4,10 +4,7 @@ export type { AttackRingInput } from "../types";
export { GraphicsOverridesSchema } from "./GraphicsOverrides";
export type { GraphicsOverrides } from "./GraphicsOverrides";
export { MapRenderer } from "./MapRenderer";
export {
preloadArialAtlasData,
preloadAtlasData,
} from "./passes/name-pass/AtlasData";
export { preloadAtlasData } from "./passes/name-pass/AtlasData";
export type { SpawnCenter } from "./passes/SpawnOverlayPass";
export { applyGraphicsOverrides } from "./RenderOverrides";
export { createRenderSettings, dumpSettings } from "./RenderSettings";
@@ -167,7 +167,7 @@ export class TextProgram {
"uHoverGlowAlpha",
)!;
this.loadAtlas(atlasUrl, this.msdf);
void this.loadAtlas(atlasUrl, this.msdf);
}
/** True when the atlas for the requested font is uploaded and drawable. */
@@ -175,32 +175,47 @@ export class TextProgram {
return (classic ? this.arial.atlasTex : this.msdf.atlasTex) !== null;
}
/** Install the classic (Arial/Arimo) MSDF atlas: metrics + async-loaded image. */
setArialFont(metricsTex: WebGLTexture, atlas: ParsedAtlas): void {
/**
* Install the classic (Arial/Arimo) MSDF atlas: metrics + async-loaded image.
* Resolves once the atlas image is uploaded, so the caller can switch fonts
* only when it's drawable.
*/
setArialFont(metricsTex: WebGLTexture, atlas: ParsedAtlas): Promise<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);
return this.loadAtlas(arialAtlasUrl, this.arial);
}
private loadAtlas(url: string, font: FontGpu): void {
private loadAtlas(url: string, font: FontGpu): Promise<void> {
const gl = this.gl;
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
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, img);
font.atlasTex = tex;
};
img.src = url;
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
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,
img,
);
font.atlasTex = tex;
resolve();
};
img.onerror = () => reject(new Error(`Failed to load atlas: ${url}`));
img.src = url;
});
}
draw(
+78 -44
View File
@@ -36,6 +36,7 @@ import {
buildKernTable,
parseArialAtlasData,
parseAtlasData,
preloadArialAtlasData,
} from "./AtlasData";
import {
buildCursorTex,
@@ -84,12 +85,15 @@ export class NamePass {
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.
// The classic (Arial) font is loaded lazily the first time it's selected.
private arialGlyph: GlyphTables | null = null;
private arialKern: Int8Array | null = null;
private arialMetricsTex: WebGLTexture | null = null;
private arialFontSize = 0;
private arialBase = 0;
private arialLoaded = false;
private arialLoading = false;
// Which font the cursor buffers are currently laid out / rendering as.
private classicFont = false;
// Player management
@@ -222,30 +226,21 @@ export class NamePass {
this.maxPlayers,
);
// 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);
// Apply the initially-selected font to the glyph tables + sibling passes.
// The classic (Arial) atlas is loaded lazily; render MSDF until it's
// selected. If it's already the chosen font, kick off the load now.
this.classicFont = false;
this.applyFont(settings.name.classicFont);
if (settings.name.classicFont) this.ensureArialFont();
}
/**
* 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.
* Point the layout tables + line metrics at the given font and push its
* metrics to the icon/status/debug passes so they stay aligned. Each font
* carries its own em size and baseline. Arial must be loaded for classic.
*/
private applyFont(classic: boolean): void {
this.classicFont = classic;
this.glyph = classic ? this.arialGlyph : this.msdfGlyph;
this.kernTable = classic ? this.arialKern : this.msdfKern;
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);
@@ -253,6 +248,55 @@ export class NamePass {
this.debugProgram.setFont(this.fontSize, this.fontBase);
}
/** Switch fonts and re-lay-out all names/troops (advances differ per font). */
private switchFont(classic: boolean): void {
this.applyFont(classic);
for (const slot of this.slots.values()) {
if (slot.nameLen > 0) {
slot.nameHalfWidth = this.uploadStringRow(
slot.index * LINES_PER_PLAYER,
slot.static.displayName,
);
}
if (slot.troopLen > 0 && slot.lastTroopStr) {
this.uploadStringRow(
slot.index * LINES_PER_PLAYER + 1,
slot.lastTroopStr,
);
}
this.writePlayerDataRow(slot);
}
}
/**
* Lazily fetch + build the Arial (Arimo) MSDF atlas on first use. Resolves
* into a font switch only once the atlas image is uploaded, so names keep
* rendering in MSDF (no blank flash) until classic is fully ready.
*/
private ensureArialFont(): void {
if (this.arialLoaded || this.arialLoading) return;
this.arialLoading = true;
preloadArialAtlasData()
.then(() => {
const atlas = parseArialAtlasData();
this.arialGlyph = buildGlyphTables(atlas.chars);
this.arialKern = buildKernTable(atlas.kernings);
this.arialMetricsTex = buildGlyphMetricsTex(this.gl, atlas);
this.arialFontSize = atlas.fontSize;
this.arialBase = atlas.base;
return this.textProgram.setArialFont(this.arialMetricsTex, atlas);
})
.then(() => {
this.arialLoaded = true;
// Switch now if it's (still) the selected font.
if (this.settings.name.classicFont) this.switchFont(true);
})
.catch((err) => {
console.error("Failed to load Arial atlas:", err);
// Leave arialLoading=true so we don't spin retrying a hard failure.
});
}
// -------------------------------------------------------------------------
// Late player registration (bots arrive on tick 1)
// -------------------------------------------------------------------------
@@ -691,28 +735,18 @@ export class NamePass {
}
/**
* Switch the active text font when settings.name.classicFont changes, then
* re-lay-out every name + troop line — advances differ between the two fonts,
* so cursor positions and name half-widths must be recomputed.
* Reconcile the rendering font with the selected one (settings.name.classicFont).
* Switching to classic lazily loads the Arial atlas first; rendering stays on
* MSDF until it's ready, then switches.
*/
private syncFont(): void {
const classic = this.settings.name.classicFont;
if (classic === this.classicFont) return;
this.applyFont(classic);
for (const slot of this.slots.values()) {
if (slot.nameLen > 0) {
slot.nameHalfWidth = this.uploadStringRow(
slot.index * LINES_PER_PLAYER,
slot.static.displayName,
);
}
if (slot.troopLen > 0 && slot.lastTroopStr) {
this.uploadStringRow(
slot.index * LINES_PER_PLAYER + 1,
slot.lastTroopStr,
);
}
this.writePlayerDataRow(slot);
const desired = this.settings.name.classicFont;
if (desired === this.classicFont) return;
if (desired) {
if (this.arialLoaded) this.switchFont(true);
else this.ensureArialFont();
} else {
this.switchFont(false);
}
}
@@ -805,7 +839,7 @@ export class NamePass {
this.statusIconProgram.dispose();
this.debugProgram.dispose();
gl.deleteTexture(this.glyphMetricsTex);
gl.deleteTexture(this.arialMetricsTex);
if (this.arialMetricsTex) gl.deleteTexture(this.arialMetricsTex);
gl.deleteTexture(this.cursorTex);
gl.deleteTexture(this.stringTex);
gl.deleteTexture(this.playerDataTex);