# Dynamic flag atlas (runtime TEXTURE_2D_ARRAY)

Replaces the build-time `flag-atlas.png` with a runtime
`TEXTURE_2D_ARRAY`
populated on demand from each player's server-resolved flag URL. Layers
are
deduped by URL (every "Mercia" bot shares one slot), so the per-game
working
set is bounded by unique flags, not player count.

## Why

The store will eventually ship hundreds of custom flags fetched from the
CDN,
which can't be baked into a static atlas. Moving to a runtime array also
lets
the catalog grow without bloating the client bundle.

## Side effect (bonus)

Human players' country flags (`country:US`, etc.) now display next to
their
names in-game. The old atlas only contained nation names, so non-nation
flags
were silently dropped.

## Notes

- Cell size is fixed at 128×85; loaded images are aspect-fit and
centered.
- Layer cap is 512 (clamped to `MAX_ARRAY_TEXTURE_LAYERS`). Past the
cap,
  further flag requests render no icon.
- Mipmaps are regenerated after each layer upload.
- Recommend store pipeline caps custom flag uploads at SVG or PNG ≤
256×170,
  ≤ 50 KB (decode-time RAM and bandwidth, not VRAM).


## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
This commit is contained in:
Evan
2026-05-22 13:19:22 +01:00
committed by GitHub
parent fe6581e3fe
commit 19beab9a70
14 changed files with 443 additions and 889 deletions
-568
View File
@@ -1,568 +0,0 @@
{
"width": 2048,
"height": 2975,
"cellW": 128,
"cellH": 85,
"cols": 16,
"flags": {
"1_Airgialla": 0,
"1_Connacht": 1,
"1_Dalriata": 2,
"1_Dumnonia": 3,
"1_Dyfed": 4,
"1_East Anglia": 5,
"1_Essex": 6,
"1_Fortriu": 7,
"1_Franks": 8,
"1_Gwent": 9,
"1_Gwynedd": 10,
"1_Kent": 11,
"1_Laigin": 12,
"1_Mercia": 13,
"1_Munster": 14,
"1_Northern Ui Neill": 15,
"1_Northumbria": 16,
"1_Occitania": 17,
"1_Powys": 18,
"1_Southern Ui Neill": 19,
"1_Strathclyde": 20,
"1_Sussex": 21,
"1_Ulaid": 22,
"1_Wessex": 23,
"Abbasid Caliphate": 24,
"Achaemenid Empire": 25,
"African union": 26,
"Alabama": 27,
"Alaska": 28,
"Alkebulan": 29,
"Amazigh flag": 30,
"American_Samoa": 31,
"Anarchist flag": 32,
"Apartheid South Africa": 33,
"Arabia": 34,
"Aram Damascus": 35,
"Arizona": 36,
"Arkansas": 37,
"Assyria": 38,
"Athens": 39,
"Australian Aboriginal Flag": 40,
"Aztec Empire": 41,
"Babylonia": 42,
"Burma": 43,
"Burma2": 44,
"Byelorussian SSR": 45,
"Byzantine Empire": 46,
"California": 47,
"Capybara": 48,
"Carthage": 49,
"Ceara": 50,
"Chinook": 51,
"Chuvashia": 52,
"Circassia": 53,
"Colchis": 54,
"Colorado": 55,
"Communist Romania": 56,
"Communist flag": 57,
"Confederate States": 58,
"Connecticut": 59,
"Corsica": 60,
"Cthulhu Republic": 61,
"Danzig": 62,
"Delaware": 63,
"Dilmun": 64,
"District_of_Columbia": 65,
"Dutch East India Company": 66,
"Elam": 67,
"Empire of Japan": 68,
"Empire of Japan1": 69,
"Essex": 70,
"Fascist Spain": 71,
"Flag_of_the_Trucial_States_(19681971)": 72,
"Flanders": 73,
"Florida": 74,
"Franks": 75,
"French foreign legion": 76,
"Garamant": 77,
"Georgia_US": 78,
"Georgian SSR": 79,
"German Empire": 80,
"Guam": 81,
"Habsburg Austria": 82,
"Hawaii": 83,
"Holy Roman Empire": 84,
"Hyrcania": 85,
"Idaho": 86,
"Illinois": 87,
"Imperial Ethiopia": 88,
"Indiana": 89,
"Iowa": 90,
"Kansas": 91,
"Kazakh SSR": 92,
"Kemet": 93,
"Kent": 94,
"Kentucky": 95,
"Khemet": 96,
"Kingdom of Egypt": 97,
"Kingdom of Iraq": 98,
"Kingdom of Jerusalem": 99,
"Kingdom of Judah": 100,
"Kingdom_of_Iraq": 101,
"Kingdom_of_Judah": 102,
"Kiwi": 103,
"Kush": 104,
"Laigin": 105,
"League of Nations": 106,
"Leinster": 107,
"Liberalism_flag": 108,
"Libyan Jamahiriya": 109,
"Lihyan": 110,
"Listenbourg": 111,
"Louisiana": 112,
"Lower Silesia": 113,
"Lydia": 114,
"Macedonia": 115,
"Maine": 116,
"Maori flag": 117,
"Maryland": 118,
"Massachusetts": 119,
"Mauritania": 120,
"Median Empire": 121,
"Michigan": 122,
"Minnesota": 123,
"Mississippi": 124,
"Missouri": 125,
"Mongol Empire": 126,
"Montana": 127,
"Munster": 128,
"NATO": 129,
"Nebraska": 130,
"Nevada": 131,
"New_Hampshire": 132,
"New_Jersey": 133,
"New_Mexico": 134,
"New_York": 135,
"Newfoundland": 136,
"North karelia": 137,
"North yemen": 138,
"North_Carolina": 139,
"North_Dakota": 140,
"Northern_Mariana_Islands": 141,
"Nunavut": 142,
"OFM": 143,
"Ohio": 144,
"Oklahoma": 145,
"Oregon": 146,
"Ottoman Empire": 147,
"Pahlavi Iran": 148,
"Palekh": 149,
"Para": 150,
"Pennsylvania": 151,
"Persia": 152,
"Phrygia": 153,
"Poland Lithuania": 154,
"PolishLithuanian Commonwealth": 155,
"Qing Dynasty": 156,
"Quebec": 157,
"Republic of China": 158,
"Republic of Egypt": 159,
"Republic of Formosa": 160,
"Republic of Korea": 161,
"Republic of Pirates": 162,
"Rhode_Island": 163,
"Rhodesia": 164,
"Romanov Russia": 165,
"Ror Empire": 166,
"Russian SSR": 167,
"SPQR": 168,
"Saba kingdom": 169,
"Sakhalin": 170,
"Sami flag": 171,
"Santa Cruz": 172,
"Sao Paulo": 173,
"Sassanid Empire": 174,
"Second Republic of Iraq": 175,
"Second Spanish Republic": 176,
"Siam": 177,
"Siberia": 178,
"Sicily": 179,
"Socialist_flag": 180,
"South Vietnam": 181,
"South_Carolina": 182,
"South_Dakota": 183,
"Sparta": 184,
"Sultanate of Nejd": 185,
"Sweden Norway Union": 186,
"Tennessee": 187,
"Texas": 188,
"Trucial States": 189,
"Turkmen SSR": 190,
"USA 1776": 191,
"Ukrainian SSR": 192,
"Ulaid": 193,
"Umayyad Caliphate": 194,
"United Arab Republic": 195,
"United_States_Virgin_Islands": 196,
"Upper Silesia": 197,
"Urartu": 198,
"Utah": 199,
"Vermont": 200,
"Virginia": 201,
"Wallonia": 202,
"Washington": 203,
"Wassex": 204,
"West Roman Empire": 205,
"West_Virginia": 206,
"Wisconsin": 207,
"Wyoming": 208,
"Yellow_Flag": 209,
"Yukon": 210,
"Zaire": 211,
"Zheleznogorsk": 212,
"ac": 213,
"ad": 214,
"ae": 215,
"af": 216,
"ag": 217,
"ai": 218,
"al": 219,
"am": 220,
"amazonas": 221,
"an_pe": 222,
"antipope": 223,
"ao": 224,
"aq": 225,
"aquitaine": 226,
"ar": 227,
"armagnac": 228,
"as": 229,
"asturias": 230,
"at": 231,
"au": 232,
"aus_norter": 233,
"aus_nsw": 234,
"aus_quelan": 235,
"aus_souaus": 236,
"aus_tas": 237,
"aus_vic": 238,
"aus_wesaus": 239,
"austria-hungary": 240,
"aw": 241,
"ax": 242,
"az": 243,
"ba": 244,
"baguette": 245,
"bahia": 246,
"bai_bur": 247,
"bai_irk": 248,
"bb": 249,
"bd": 250,
"be": 251,
"bf": 252,
"bg": 253,
"bh": 254,
"bi": 255,
"bj": 256,
"bl": 257,
"bm": 258,
"bn": 259,
"bo": 260,
"bq": 261,
"br": 262,
"brittany": 263,
"bs": 264,
"bt": 265,
"buenos_aires": 266,
"bulgaria": 267,
"burgundy": 268,
"bv": 269,
"bw": 270,
"by": 271,
"bz": 272,
"ca": 273,
"ca_nb": 274,
"ca_ns": 275,
"ca_pe": 276,
"castille": 277,
"catalonia": 278,
"catamarca": 279,
"cc": 280,
"cd": 281,
"cf": 282,
"cg": 283,
"ch": 284,
"ci": 285,
"ck": 286,
"cl": 287,
"cm": 288,
"cn": 289,
"co": 290,
"cordoba": 291,
"cp": 292,
"cr": 293,
"cu": 294,
"cv": 295,
"cw": 296,
"cx": 297,
"cy": 298,
"cz": 299,
"de": 300,
"denmark": 301,
"dg": 302,
"dj": 303,
"dk": 304,
"dm": 305,
"do": 306,
"dz": 307,
"east_germany": 308,
"ec": 309,
"ee": 310,
"eg": 311,
"eh": 312,
"eo": 313,
"er": 314,
"es-ct": 315,
"es-ga": 316,
"es-pv": 317,
"es": 318,
"estonia": 319,
"et": 320,
"eu": 321,
"fi": 322,
"finland": 323,
"fj": 324,
"fk": 325,
"fm": 326,
"fo": 327,
"fr": 328,
"frost_giant": 329,
"ga": 330,
"galapagos": 331,
"gb-eng": 332,
"gb-sct": 333,
"gb-wls": 334,
"gb": 335,
"gd": 336,
"ge": 337,
"gf": 338,
"gg": 339,
"gh": 340,
"gi": 341,
"gl": 342,
"gm": 343,
"gn": 344,
"gp": 345,
"gq": 346,
"gr": 347,
"granada": 348,
"greece": 349,
"gs": 350,
"gt": 351,
"gu": 352,
"gw": 353,
"gy": 354,
"ha_ma": 355,
"hk": 356,
"hm": 357,
"hn": 358,
"hr": 359,
"ht": 360,
"hu": 361,
"hungary": 362,
"ic": 363,
"iceland": 364,
"id": 365,
"ie": 366,
"il": 367,
"im": 368,
"in": 369,
"io": 370,
"iq": 371,
"ir": 372,
"iraq": 373,
"ireland": 374,
"is": 375,
"it": 376,
"italy": 377,
"je": 378,
"jm": 379,
"jo": 380,
"jp": 381,
"ke": 382,
"kg": 383,
"kh": 384,
"ki": 385,
"km": 386,
"kn": 387,
"kp": 388,
"kr": 389,
"kurdistan": 390,
"kw": 391,
"ky": 392,
"kz": 393,
"la": 394,
"latvia": 395,
"lb": 396,
"lc": 397,
"leon": 398,
"li": 399,
"lithuania": 400,
"lk": 401,
"lr": 402,
"ls": 403,
"lt": 404,
"lu": 405,
"lv": 406,
"ly": 407,
"ma": 408,
"mc": 409,
"md": 410,
"me": 411,
"mf": 412,
"mg": 413,
"mh": 414,
"minas_gerais": 415,
"mk": 416,
"ml": 417,
"mm": 418,
"mn": 419,
"mo": 420,
"mp": 421,
"mq": 422,
"mr": 423,
"ms": 424,
"mt": 425,
"mu": 426,
"mv": 427,
"mw": 428,
"mx": 429,
"my": 430,
"mz": 431,
"na": 432,
"nc": 433,
"ne": 434,
"netherlands": 435,
"neuragic_empire": 436,
"nf": 437,
"ng": 438,
"ni": 439,
"nl": 440,
"no": 441,
"normandy": 442,
"northern_ireland": 443,
"norway": 444,
"np": 445,
"nr": 446,
"nu": 447,
"nz": 448,
"om": 449,
"pa": 450,
"paris": 451,
"pe": 452,
"pf": 453,
"pg": 454,
"ph": 455,
"pk": 456,
"pl": 457,
"pm": 458,
"pn": 459,
"poland": 460,
"polar_bears": 461,
"portugal": 462,
"pr": 463,
"provence": 464,
"prussia": 465,
"ps": 466,
"pt": 467,
"pw": 468,
"py": 469,
"qa": 470,
"re": 471,
"rio_de_janeiro": 472,
"ro": 473,
"rs": 474,
"ru": 475,
"rw": 476,
"sa": 477,
"santa_claus": 478,
"santa_cruz": 479,
"sardines": 480,
"sb": 481,
"sc": 482,
"sd": 483,
"se": 484,
"seville": 485,
"sg": 486,
"sh-ac": 487,
"sh-hl": 488,
"sh-ta": 489,
"sh": 490,
"sh_yugo": 491,
"si": 492,
"sj": 493,
"sk": 494,
"sl": 495,
"sm": 496,
"sn": 497,
"so": 498,
"south yemen": 499,
"spain": 500,
"spanish_empire": 501,
"sr": 502,
"ss": 503,
"st": 504,
"sv": 505,
"sweden": 506,
"sx": 507,
"sy": 508,
"sz": 509,
"ta": 510,
"tc": 511,
"td": 512,
"tf": 513,
"tg": 514,
"th": 515,
"tibet": 516,
"tj": 517,
"tk": 518,
"tl": 519,
"tm": 520,
"tn": 521,
"to": 522,
"toki_pona": 523,
"tr": 524,
"tt": 525,
"tv": 526,
"tw": 527,
"tz": 528,
"ua": 529,
"ug": 530,
"uk": 531,
"uk_us_flag": 532,
"um": 533,
"un": 534,
"us": 535,
"ussr": 536,
"uy": 537,
"uz": 538,
"va": 539,
"valencia": 540,
"vc": 541,
"ve": 542,
"venice": 543,
"vg": 544,
"vi": 545,
"vn": 546,
"vu": 547,
"west_germany": 548,
"wf": 549,
"ws": 550,
"xk": 551,
"xx": 552,
"ye": 553,
"yt": 554,
"yugoslavia": 555,
"za": 556,
"zm": 557,
"zw": 558
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 995 KiB

+7 -6
View File
@@ -1,6 +1,6 @@
import { Colord } from "colord";
import { base64url } from "jose";
import { extractFlagName } from "../core/AssetUrls";
import { assetUrl } from "../core/AssetUrls";
import { decodePatternData } from "../core/PatternDecoder";
import { PlayerType } from "../core/game/Game";
import { GameView } from "../core/game/GameView";
@@ -134,10 +134,11 @@ export class WebGLFrameBuilder {
this.writePaletteEntry(smallID, p.territoryColor(), p.borderColor());
let flag = p.cosmetics.flag;
if (flag) {
flag = extractFlagName(flag);
}
// p.cosmetics.flag has already been server-resolved to either a full URL
// or a relative asset path (e.g. "/flags/US.svg" or a CDN URL for a
// custom flag). assetUrl() passes URLs through and rewrites paths.
const flagRef = p.cosmetics.flag;
const flagUrl = flagRef ? assetUrl(flagRef) : undefined;
const pattern = p.cosmetics.pattern;
if (pattern && pattern.patternData) {
@@ -160,7 +161,7 @@ export class WebGLFrameBuilder {
newPlayers.push({
...p.static,
flag: flag,
flag: flagUrl,
color: p.territoryColor().toHex(),
});
}
@@ -4,7 +4,6 @@
*/
import emojiAtlasMeta from "resources/atlases/emoji-atlas-meta.json";
import flagAtlasMeta from "resources/atlases/flag-atlas-meta.json";
import atlasData from "resources/atlases/msdf-atlas.json";
import type { BMChar, BMKerning, ParsedAtlas } from "./Types";
import { CHAR_RANGE } from "./Types";
@@ -67,15 +66,6 @@ export function buildKernTable(kernings: BMKerning[]): Int8Array {
// Icon atlas lookups
// ---------------------------------------------------------------------------
export function buildFlagLookup(): Map<string, number> {
const map = new Map<string, number>();
const meta = flagAtlasMeta as { flags: Record<string, number> };
for (const [code, idx] of Object.entries(meta.flags)) {
map.set(code, idx);
}
return map;
}
export function buildEmojiLookup(): Map<string, number> {
const map = new Map<string, number>();
const meta = emojiAtlasMeta as { emojis: Record<string, number> };
@@ -5,13 +5,16 @@
* The shared playerDataTex is passed in but not owned/deleted.
*/
import flagAtlasMeta from "resources/atlases/flag-atlas-meta.json";
import type { RenderSettings } from "../../RenderSettings";
import debugBoxFragSrc from "../../shaders/name/debug-box.frag.glsl?raw";
import debugBoxVertSrc from "../../shaders/name/debug-box.vert.glsl?raw";
import { createProgram } from "../../utils/GlUtils";
import type { ParsedAtlas } from "./Types";
// Must match FLAG_CELL_W / FLAG_CELL_H in FlagAtlasArray.ts.
const FLAG_CELL_W = 128;
const FLAG_CELL_H = 85;
export class DebugProgram {
private gl: WebGL2RenderingContext;
private program: WebGLProgram;
@@ -35,7 +38,6 @@ export class DebugProgram {
this.playerDataTex = playerDataTex;
this.maxPlayers = maxPlayers;
const fm = flagAtlasMeta as any;
this.program = createProgram(gl, debugBoxVertSrc, debugBoxFragSrc);
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uPlayerData"), 0);
@@ -44,8 +46,14 @@ export class DebugProgram {
atlas.fontSize,
);
gl.uniform1f(gl.getUniformLocation(this.program, "uFontBase")!, atlas.base);
gl.uniform1f(gl.getUniformLocation(this.program, "uFlagCellW")!, fm.cellW);
gl.uniform1f(gl.getUniformLocation(this.program, "uFlagCellH")!, fm.cellH);
gl.uniform1f(
gl.getUniformLocation(this.program, "uFlagCellW")!,
FLAG_CELL_W,
);
gl.uniform1f(
gl.getUniformLocation(this.program, "uFlagCellH")!,
FLAG_CELL_H,
);
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uTime = gl.getUniformLocation(this.program, "uTime")!;
@@ -0,0 +1,151 @@
/**
* FlagAtlasArray — runtime TEXTURE_2D_ARRAY of player flag images.
*
* Replaces the build-time flag atlas. Layers are assigned on demand as players
* arrive, keyed by URL so identical flags share a layer (every "Mercia" bot
* costs one slot, not one per player). Images are fetched async and drawn into
* a fixed-size cell so all layers have the same dimensions.
*
* When a layer becomes ready, `onLayerReady(url, layer)` fires so the owning
* pass can flip slots from -1 to the assigned layer.
*
* Layers are not reclaimed; if the cap is hit, further requests return -1 and
* render no icon.
*/
const FLAG_CELL_W = 128;
const FLAG_CELL_H = 85;
/** Hard cap on unique flags per game. Real working set is ~50200. */
export const MAX_FLAG_LAYERS = 512;
interface PendingEntry {
layer: number;
ready: boolean;
}
export class FlagAtlasArray {
private gl: WebGL2RenderingContext;
private tex: WebGLTexture;
private layerCount: number;
private nextLayer = 0;
private entries = new Map<string, PendingEntry>();
private onLayerReady: (url: string, layer: number) => void;
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
constructor(
gl: WebGL2RenderingContext,
onLayerReady: (url: string, layer: number) => void,
) {
this.gl = gl;
this.onLayerReady = onLayerReady;
const maxLayers = gl.getParameter(gl.MAX_ARRAY_TEXTURE_LAYERS) as number;
this.layerCount = Math.min(MAX_FLAG_LAYERS, maxLayers);
this.tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.tex);
gl.texStorage3D(
gl.TEXTURE_2D_ARRAY,
mipLevels(FLAG_CELL_W, FLAG_CELL_H),
gl.RGBA8,
FLAG_CELL_W,
FLAG_CELL_H,
this.layerCount,
);
gl.texParameteri(
gl.TEXTURE_2D_ARRAY,
gl.TEXTURE_MIN_FILTER,
gl.LINEAR_MIPMAP_LINEAR,
);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
this.canvas = document.createElement("canvas");
this.canvas.width = FLAG_CELL_W;
this.canvas.height = FLAG_CELL_H;
this.ctx = this.canvas.getContext("2d", { willReadFrequently: false })!;
}
get texture(): WebGLTexture {
return this.tex;
}
/** Layer index for an already-loaded URL, or -1 if pending/missing/unassigned. */
getLayer(url: string): number {
const e = this.entries.get(url);
return e && e.ready ? e.layer : -1;
}
/**
* Request a flag. Returns immediately; `onLayerReady` fires once the image is
* loaded and uploaded. Subsequent calls for the same URL are no-ops.
*/
request(url: string): void {
if (this.entries.has(url)) return;
if (this.nextLayer >= this.layerCount) return; // hit cap → no icon
const layer = this.nextLayer++;
const entry: PendingEntry = { layer, ready: false };
this.entries.set(url, entry);
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
// Draw into a fixed-size cell to normalize the image to layer dimensions.
// Center via aspect-fit so non-3:2 flags don't stretch.
this.ctx.clearRect(0, 0, FLAG_CELL_W, FLAG_CELL_H);
const srcAspect = img.width / img.height;
const dstAspect = FLAG_CELL_W / FLAG_CELL_H;
let dw: number, dh: number;
if (srcAspect > dstAspect) {
dw = FLAG_CELL_W;
dh = FLAG_CELL_W / srcAspect;
} else {
dh = FLAG_CELL_H;
dw = FLAG_CELL_H * srcAspect;
}
const dx = (FLAG_CELL_W - dw) * 0.5;
const dy = (FLAG_CELL_H - dh) * 0.5;
this.ctx.drawImage(img, dx, dy, dw, dh);
const gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.tex);
gl.texSubImage3D(
gl.TEXTURE_2D_ARRAY,
0,
0,
0,
layer,
FLAG_CELL_W,
FLAG_CELL_H,
1,
gl.RGBA,
gl.UNSIGNED_BYTE,
this.canvas,
);
gl.generateMipmap(gl.TEXTURE_2D_ARRAY);
entry.ready = true;
this.onLayerReady(url, layer);
};
img.onerror = () => {
// Leave entry as not-ready forever; layer is consumed but harmless.
console.warn("Flag image failed to load:", url);
};
img.src = url;
}
dispose(): void {
this.gl.deleteTexture(this.tex);
this.entries.clear();
}
}
function mipLevels(w: number, h: number): number {
return Math.floor(Math.log2(Math.max(w, h))) + 1;
}
@@ -1,31 +1,36 @@
/**
* IconProgram — instanced flag + emoji icons beside player names.
*
* Owns: shader program, uniform locations, flag atlas + emoji atlas textures.
* The shared playerDataTex is passed in but not owned/deleted.
* Owns the shader program and the emoji atlas texture. The flag texture is a
* sampler2DArray populated at runtime by FlagAtlasArray (passed in, not owned).
* The shared playerDataTex is also passed in but not owned/deleted.
*/
import emojiAtlasMeta from "resources/atlases/emoji-atlas-meta.json";
import flagAtlasMeta from "resources/atlases/flag-atlas-meta.json";
import { assetUrl } from "src/core/AssetUrls";
import type { RenderSettings } from "../../RenderSettings";
import iconFragSrc from "../../shaders/name/icon.frag.glsl?raw";
import iconVertSrc from "../../shaders/name/icon.vert.glsl?raw";
import { createProgram } from "../../utils/GlUtils";
import type { FlagAtlasArray } from "./FlagAtlasArray";
import type { ParsedAtlas } from "./Types";
const emojiAtlasUrl = assetUrl("atlases/emoji-atlas.png");
const flagAtlasUrl = assetUrl("atlases/flag-atlas.png");
// Must match FLAG_CELL_W / FLAG_CELL_H in FlagAtlasArray.ts. Used only for
// world-space aspect ratio of the flag quad.
const FLAG_CELL_W = 128;
const FLAG_CELL_H = 85;
export class IconProgram {
private gl: WebGL2RenderingContext;
private program: WebGLProgram;
private playerDataTex: WebGLTexture;
private flagAtlas: FlagAtlasArray;
private maxPlayers: number;
private flagAtlasTex: WebGLTexture | null = null;
private emojiAtlasTex: WebGLTexture | null = null;
private iconsReady = false;
private emojiReady = false;
// Dynamic uniform locations
private uCamera: WebGLUniformLocation;
@@ -40,10 +45,12 @@ export class IconProgram {
gl: WebGL2RenderingContext,
atlas: ParsedAtlas,
playerDataTex: WebGLTexture,
flagAtlas: FlagAtlasArray,
maxPlayers: number,
) {
this.gl = gl;
this.playerDataTex = playerDataTex;
this.flagAtlas = flagAtlas;
this.maxPlayers = maxPlayers;
this.program = createProgram(gl, iconVertSrc, iconFragSrc);
@@ -55,20 +62,19 @@ export class IconProgram {
gl.uniform1i(gl.getUniformLocation(this.program, "uEmojiAtlas"), 2);
// Static uniforms from atlas metadata
const fm = flagAtlasMeta as any;
const em = emojiAtlasMeta as any;
gl.uniform1f(
gl.getUniformLocation(this.program, "uFontSize")!,
atlas.fontSize,
);
gl.uniform1f(gl.getUniformLocation(this.program, "uFontBase")!, atlas.base);
gl.uniform1f(gl.getUniformLocation(this.program, "uFlagCellW")!, fm.cellW);
gl.uniform1f(gl.getUniformLocation(this.program, "uFlagCellH")!, fm.cellH);
gl.uniform1f(gl.getUniformLocation(this.program, "uFlagCols")!, fm.cols);
gl.uniform1f(gl.getUniformLocation(this.program, "uFlagAtlasW")!, fm.width);
gl.uniform1f(
gl.getUniformLocation(this.program, "uFlagAtlasH")!,
fm.height,
gl.getUniformLocation(this.program, "uFlagCellW")!,
FLAG_CELL_W,
);
gl.uniform1f(
gl.getUniformLocation(this.program, "uFlagCellH")!,
FLAG_CELL_H,
);
gl.uniform1f(
gl.getUniformLocation(this.program, "uEmojiCell")!,
@@ -102,52 +108,34 @@ export class IconProgram {
"uEmojiRowOffset",
)!;
this.loadAtlases();
this.loadEmojiAtlas();
}
get ready(): boolean {
return this.iconsReady;
return this.emojiReady;
}
private loadAtlases(): void {
private loadEmojiAtlas(): void {
const gl = this.gl;
const load = (url: string, cb: (tex: WebGLTexture) => void) => {
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_MIPMAP_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,
);
gl.generateMipmap(gl.TEXTURE_2D);
cb(tex);
};
img.src = url;
};
load(flagAtlasUrl, (tex) => {
this.flagAtlasTex = tex;
this.iconsReady =
this.flagAtlasTex !== null && this.emojiAtlasTex !== null;
});
load(emojiAtlasUrl, (tex) => {
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_MIPMAP_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);
gl.generateMipmap(gl.TEXTURE_2D);
this.emojiAtlasTex = tex;
this.iconsReady =
this.flagAtlasTex !== null && this.emojiAtlasTex !== null;
});
this.emojiReady = true;
};
img.src = emojiAtlasUrl;
}
draw(
@@ -155,7 +143,7 @@ export class IconProgram {
settings: RenderSettings,
vao: WebGLVertexArrayObject,
): void {
if (!this.iconsReady) return;
if (!this.emojiReady) return;
const gl = this.gl;
const ns = settings.name;
@@ -172,7 +160,7 @@ export class IconProgram {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.playerDataTex);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.flagAtlasTex!);
gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.flagAtlas.texture);
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, this.emojiAtlasTex!);
@@ -183,7 +171,6 @@ export class IconProgram {
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
if (this.flagAtlasTex) gl.deleteTexture(this.flagAtlasTex);
if (this.emojiAtlasTex) gl.deleteTexture(this.emojiAtlasTex);
}
}
@@ -56,7 +56,10 @@ export interface PlayerSlot {
nameLen: number;
troopLen: number;
lastTroopStr: string;
flagAtlasIdx: number;
/** URL identifying which flag this player wants (dedup key). undefined = none. */
flagUrl: string | undefined;
/** Layer index in FlagAtlasArray, or -1 if not loaded yet / no flag. */
flagLayerIdx: number;
emojiAtlasIdx: number;
nameHalfWidth: number;
+50 -11
View File
@@ -30,7 +30,6 @@ import { createFullscreenQuad } from "../../utils/GlUtils";
import type { GlyphTables } from "./AtlasData";
import {
buildEmojiLookup,
buildFlagLookup,
buildGlyphTables,
buildKernTable,
parseAtlasData,
@@ -42,6 +41,7 @@ import {
buildStringTex,
} from "./DataTextures";
import { DebugProgram } from "./DebugProgram";
import { FlagAtlasArray } from "./FlagAtlasArray";
import { IconProgram } from "./IconProgram";
import { StatusIconProgram } from "./StatusIconProgram";
import { formatTroops, layoutString } from "./TextLayout";
@@ -78,8 +78,10 @@ export class NamePass {
private slots: Map<string, PlayerSlot> = new Map();
private maxPlayers: number;
private playerColors: Map<string, [number, number, number]> = new Map();
private flagCodeToIndex: Map<string, number>;
private flagAtlas: FlagAtlasArray;
private emojiCharToIndex: Map<string, number>;
/** Slots waiting on a flag URL → set of slots that want that URL's layer. */
private slotsWaitingForFlag = new Map<string, Set<PlayerSlot>>();
// CPU-side mirrors — batched upload in draw()
private cpuPlayerData: Float32Array;
@@ -112,9 +114,22 @@ export class NamePass {
const atlas = parseAtlasData();
this.glyph = buildGlyphTables(atlas.chars);
this.kernTable = buildKernTable(atlas.kernings);
this.flagCodeToIndex = buildFlagLookup();
this.emojiCharToIndex = buildEmojiLookup();
// Runtime flag-image manager (TEXTURE_2D_ARRAY of player flags, fetched
// on demand from URLs). When a flag finishes loading, flip every slot
// that wanted that URL from layer -1 to the resolved layer.
this.flagAtlas = new FlagAtlasArray(gl, (url, layer) => {
const waiting = this.slotsWaitingForFlag.get(url);
if (!waiting) return;
this.slotsWaitingForFlag.delete(url);
for (const slot of waiting) {
if (slot.flagUrl !== url) continue; // player changed flag while we waited
slot.flagLayerIdx = layer;
this.writePlayerDataRow(slot);
}
});
// Build player lookups and extract territory colors from palette
this.playerByID = new Map();
this.smallIDToPlayerID = new Map();
@@ -157,6 +172,7 @@ export class NamePass {
gl,
atlas,
this.playerDataTex,
this.flagAtlas,
this.maxPlayers,
);
this.statusIconProgram = new StatusIconProgram(
@@ -192,6 +208,28 @@ export class NamePass {
}
}
/**
* Request the texture layer for a slot's flag (called once at slot creation).
* If the image is already loaded the layer index is set immediately; otherwise
* the slot joins a wait list and is updated when the image arrives.
*/
private resolveSlotFlag(slot: PlayerSlot): void {
const url = slot.flagUrl;
if (!url) return;
this.flagAtlas.request(url);
const layer = this.flagAtlas.getLayer(url);
if (layer >= 0) {
slot.flagLayerIdx = layer;
return;
}
let waiting = this.slotsWaitingForFlag.get(url);
if (!waiting) {
waiting = new Set();
this.slotsWaitingForFlag.set(url, waiting);
}
waiting.add(slot);
}
// -------------------------------------------------------------------------
// Name updates — called by GPURenderer
// -------------------------------------------------------------------------
@@ -223,8 +261,7 @@ export class NamePass {
let nextSlotIndex = 0;
for (const p of this.playerByID.values()) {
if (!this.slots.has(p.id)) {
const flagCode = p.flag;
this.slots.set(p.id, {
const slot: PlayerSlot = {
index: nextSlotIndex++,
playerID: p.id,
static: p,
@@ -239,9 +276,8 @@ export class NamePass {
nameLen: 0,
troopLen: 0,
lastTroopStr: "",
flagAtlasIdx: flagCode
? (this.flagCodeToIndex.get(flagCode) ?? -1)
: -1,
flagUrl: p.flag,
flagLayerIdx: -1,
emojiAtlasIdx: -1,
nameHalfWidth: 0,
crown: false,
@@ -255,7 +291,9 @@ export class NamePass {
nukeTargetsMe: false,
traitorRemainingTicks: 0,
allianceFraction: 0,
});
};
this.slots.set(p.id, slot);
this.resolveSlotFlag(slot);
} else {
nextSlotIndex = Math.max(
nextSlotIndex,
@@ -457,8 +495,8 @@ export class NamePass {
d[off + 14] = slot.static.playerType === PlayerTypeEnum.Human ? 1.0 : 0.0;
d[off + 15] = slot.nameHalfWidth;
// Column 4: flagAtlasIdx, emojiAtlasIdx, [free], [free]
d[off + 16] = slot.flagAtlasIdx;
// Column 4: flagLayerIdx, emojiAtlasIdx, [free], [free]
d[off + 16] = slot.flagLayerIdx;
d[off + 17] = slot.emojiAtlasIdx;
d[off + 18] = 0;
d[off + 19] = 0;
@@ -562,6 +600,7 @@ export class NamePass {
const gl = this.gl;
this.textProgram.dispose();
this.iconProgram.dispose();
this.flagAtlas.dispose();
this.statusIconProgram.dispose();
this.debugProgram.dispose();
gl.deleteTexture(this.glyphMetricsTex);
@@ -1,11 +1,13 @@
#version 300 es
precision highp float;
precision highp sampler2DArray;
uniform sampler2D uFlagAtlas;
uniform sampler2D uEmojiAtlas;
uniform sampler2DArray uFlagAtlas;
uniform sampler2D uEmojiAtlas;
in vec2 vUV;
flat in int vIconType; // 0 = flag, 1 = emoji, -1 = discard
flat in int vIconType; // 0 = flag, 1 = emoji, -1 = discard
flat in int vFlagLayer;
out vec4 fragColor;
@@ -14,7 +16,7 @@ void main() {
vec4 texel;
if (vIconType == 0) {
texel = texture(uFlagAtlas, vUV);
texel = texture(uFlagAtlas, vec3(vUV, float(vFlagLayer)));
} else {
texel = texture(uEmojiAtlas, vUV);
}
@@ -18,12 +18,9 @@ uniform float uNameScaleCap;
uniform float uFontSize; // atlas reference font size (same as name shader's uFontSize)
uniform float uFontBase; // atlas baseline height (same as name shader's uBase)
// Flag atlas layout
uniform float uFlagCellW; // texels per flag cell (width)
uniform float uFlagCellH; // texels per flag cell (height)
uniform float uFlagCols; // columns in flag atlas
uniform float uFlagAtlasW; // flag atlas texture width
uniform float uFlagAtlasH; // flag atlas texture height
// Flag cell shape (fixed, matches FlagAtlasArray cell size)
uniform float uFlagCellW;
uniform float uFlagCellH;
// Emoji atlas layout
uniform float uEmojiCell; // texels per emoji cell (square)
@@ -35,7 +32,8 @@ uniform float uEmojiAtlasH; // emoji atlas texture height
uniform float uEmojiRowOffset;
out vec2 vUV;
flat out int vIconType; // 0 = flag, 1 = emoji, -1 = discard
flat out int vIconType; // 0 = flag, 1 = emoji, -1 = discard
flat out int vFlagLayer; // valid when vIconType == 0
void main() {
// Decode instance ID → playerIdx + iconType (0=flag, 1=emoji)
@@ -46,22 +44,24 @@ void main() {
vec4 pd0 = texelFetch(uPlayerData, ivec2(0, playerIdx), 0); // srcX, srcY, srcScale, startTime
vec4 pd1 = texelFetch(uPlayerData, ivec2(1, playerIdx), 0); // tgtX, tgtY, tgtScale, alive
vec4 pd3 = texelFetch(uPlayerData, ivec2(3, playerIdx), 0); // nameLen, troopLen, isHuman, nameHalfWidth
vec4 pd4 = texelFetch(uPlayerData, ivec2(4, playerIdx), 0); // flagIdx, emojiIdx, [free], [free]
vec4 pd4 = texelFetch(uPlayerData, ivec2(4, playerIdx), 0); // flagLayer, emojiIdx, [free], [free]
// Early out: dead player
if (pd1.w <= 0.0) {
gl_Position = vec4(0.0);
vUV = vec2(0.0);
vIconType = -1;
vFlagLayer = 0;
return;
}
// Get atlas index for this icon type
// Get atlas/layer index for this icon type
float atlasIdx = (iconType == 0) ? pd4.x : pd4.y;
if (atlasIdx < 0.0) {
gl_Position = vec4(0.0);
vUV = vec2(0.0);
vIconType = -1;
vFlagLayer = 0;
return;
}
@@ -85,6 +85,7 @@ void main() {
gl_Position = vec4(0.0);
vUV = vec2(0.0);
vIconType = -1;
vFlagLayer = 0;
return;
}
@@ -92,46 +93,49 @@ void main() {
// Compute icon size and position based on type
float iconW, iconH;
float cellW, cellH, cols, atlasW, atlasH;
vec2 iconOrigin;
if (iconType == 0) {
// FLAG — to the left of the name
cellW = uFlagCellW;
cellH = uFlagCellH;
cols = uFlagCols;
atlasW = uFlagAtlasW;
atlasH = uFlagAtlasH;
// Flag world size: height matches ~120% of the name text height
// FLAG — to the left of the name. Sampled from sampler2DArray; uses
// plain [0,1] UVs and the layer is passed via vFlagLayer.
float flagWorldH = uFontBase * nameWorldScale * 1.2;
float flagWorldW = flagWorldH * (cellW / cellH);
float flagWorldW = flagWorldH * (uFlagCellW / uFlagCellH);
// Position: left of name, vertically centered on the name baseline
iconOrigin = vec2(
wx - nameHalfWidth * nameWorldScale - flagWorldW,
wy - flagWorldH * 0.5
);
iconW = flagWorldW;
iconH = flagWorldH;
} else {
// EMOJI — above the name
cellW = uEmojiCell;
cellH = uEmojiCell;
cols = uEmojiCols;
atlasW = uEmojiAtlasW;
atlasH = uEmojiAtlasH;
// Emoji world size: slightly larger than name text height
vUV = aPos;
vFlagLayer = int(atlasIdx);
} else {
// EMOJI — above the name. Sampled from a 2D atlas; compute grid UVs.
float cellW = uEmojiCell;
float cellH = uEmojiCell;
float cols = uEmojiCols;
float atlasW = uEmojiAtlasW;
float atlasH = uEmojiAtlasH;
float emojiWorldSize = uFontBase * nameWorldScale * 1.0;
// Position: centered above name
iconOrigin = vec2(
wx - emojiWorldSize * 0.5,
wy - uFontBase * nameWorldScale * uEmojiRowOffset
);
iconW = emojiWorldSize;
iconH = emojiWorldSize;
int idx = int(atlasIdx);
int col = idx - (idx / int(cols)) * int(cols);
int row = idx / int(cols);
float u0 = float(col) * cellW / atlasW;
float v0 = float(row) * cellH / atlasH;
float u1 = u0 + cellW / atlasW;
float v1 = v0 + cellH / atlasH;
vUV = vec2(mix(u0, u1, aPos.x), mix(v0, v1, aPos.y));
vFlagLayer = 0;
}
// Quad world position
@@ -141,14 +145,5 @@ void main() {
vec3 clip = uCamera * vec3(worldPos, 1.0);
gl_Position = vec4(clip.xy, 0.0, 1.0);
// UV from atlas grid
int idx = int(atlasIdx);
int col = idx - (idx / int(cols)) * int(cols);
int row = idx / int(cols);
float u0 = float(col) * cellW / atlasW;
float v0 = float(row) * cellH / atlasH;
float u1 = u0 + cellW / atlasW;
float v1 = v0 + cellH / atlasH;
vUV = vec2(mix(u0, u1, aPos.x), mix(v0, v1, aPos.y));
vIconType = iconType;
}
+1
View File
@@ -24,6 +24,7 @@ export interface PlayerStatic {
playerType: PlayerTypeEnum;
team: string | null;
isLobbyCreator: boolean;
/** Resolved flag image URL, or undefined for no flag. */
flag?: string;
/** Hex color (e.g. "#ff0000"). Populated from territoryColor (live) or palette (replay). */
color?: string;
-16
View File
@@ -100,22 +100,6 @@ export function assetUrl(path: string): string {
return buildAssetUrl(path, getAssetManifest(), getCdnBase());
}
/**
* Extracts a clean flag name from a cosmetic flag string, which could be
* a literal "flag:Name" token or a full CDN URL (e.g. ".../flags/Name.hash.svg").
*/
export function extractFlagName(flagData: string): string {
if (flagData.startsWith("flag:")) {
return flagData.slice(5);
}
const match = flagData.match(/\/flags\/([^?#]+)\.svg/);
if (match) {
const raw = match[1].replace(/\.[a-f0-9]{8,12}$/i, "");
return safeDecodeAssetSegment(raw);
}
return flagData;
}
// Rewrites Vite's emitted /assets/... references in the built index.html to
// use the cdnBaseRaw EJS placeholder, so RenderHtml.ts can prefix them with
// CDN_BASE at request time. Scoped to src=/href= attribute values so inline
+1 -40
View File
@@ -1,9 +1,5 @@
import { describe, expect, test } from "vitest";
import {
buildAssetUrl,
extractFlagName,
rewriteAssetsForCdn,
} from "../src/core/AssetUrls";
import { buildAssetUrl, rewriteAssetsForCdn } from "../src/core/AssetUrls";
describe("AssetUrls", () => {
test("returns hashed URLs for direct asset matches", () => {
@@ -106,41 +102,6 @@ describe("AssetUrls", () => {
});
});
describe("extractFlagName", () => {
test("extracts from flag: prefix", () => {
expect(extractFlagName("flag:Abbasid Caliphate")).toBe("Abbasid Caliphate");
});
test("extracts and decodes from URL", () => {
expect(
extractFlagName(
"https://cdn.ofedge.dev/game_assets/_assets/flags/Abbasid%20Caliphate.ed17b262829c.svg",
),
).toBe("Abbasid Caliphate");
expect(extractFlagName("/flags/Abbasid%20Caliphate.svg")).toBe(
"Abbasid Caliphate",
);
});
test("returns raw string if not matching flag: or /flags/ URL", () => {
expect(extractFlagName("Some Random String")).toBe("Some Random String");
});
test("handles malformed percent-encodings without throwing", () => {
expect(extractFlagName("/flags/Bad%C2Encoding.12345678.svg")).toBe(
"Bad%C2Encoding",
);
});
test("handles edge cases like names containing colons or URLs with hashes", () => {
expect(
extractFlagName(
"/flags/Name%3AWith%3AColons.12345678.svg?query=1#anchor",
),
).toBe("Name:With:Colons");
});
});
describe("rewriteAssetsForCdn", () => {
test("rewrites src=/assets/ to EJS placeholder", () => {
const out = rewriteAssetsForCdn(