# 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
+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,24 +1,26 @@
#version 300 es
precision highp float;
uniform sampler2D uFlagAtlas;
uniform sampler2D uEmojiAtlas;
in vec2 vUV;
flat in int vIconType; // 0 = flag, 1 = emoji, -1 = discard
out vec4 fragColor;
void main() {
if (vIconType < 0) discard;
vec4 texel;
if (vIconType == 0) {
texel = texture(uFlagAtlas, vUV);
} else {
texel = texture(uEmojiAtlas, vUV);
}
if (texel.a < 0.01) discard;
fragColor = texel;
}
#version 300 es
precision highp float;
precision highp sampler2DArray;
uniform sampler2DArray uFlagAtlas;
uniform sampler2D uEmojiAtlas;
in vec2 vUV;
flat in int vIconType; // 0 = flag, 1 = emoji, -1 = discard
flat in int vFlagLayer;
out vec4 fragColor;
void main() {
if (vIconType < 0) discard;
vec4 texel;
if (vIconType == 0) {
texel = texture(uFlagAtlas, vec3(vUV, float(vFlagLayer)));
} else {
texel = texture(uEmojiAtlas, vUV);
}
if (texel.a < 0.01) discard;
fragColor = texel;
}
+149 -154
View File
@@ -1,154 +1,149 @@
#version 300 es
precision highp float;
precision highp int;
// Unit quad vertex position [0,0]→[1,1]
layout(location = 0) in vec2 aPos;
// Data textures (shared with name shader)
uniform sampler2D uPlayerData; // PLAYER_DATA_COLS × MAX_PLAYERS, RGBA32F
// Uniforms
uniform mat3 uCamera;
uniform float uTime;
uniform float uLerpSpeed;
uniform float uCullThreshold;
uniform float uNameScaleFactor;
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
// Emoji atlas layout
uniform float uEmojiCell; // texels per emoji cell (square)
uniform float uEmojiCols; // columns in emoji atlas
uniform float uEmojiAtlasW; // emoji atlas texture width
uniform float uEmojiAtlasH; // emoji atlas texture height
// Row offset (multiples of uFontBase * nameWorldScale)
uniform float uEmojiRowOffset;
out vec2 vUV;
flat out int vIconType; // 0 = flag, 1 = emoji, -1 = discard
void main() {
// Decode instance ID playerIdx + iconType (0=flag, 1=emoji)
int playerIdx = gl_InstanceID / 2;
int iconType = gl_InstanceID - playerIdx * 2;
// Read player data
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]
// Early out: dead player
if (pd1.w <= 0.0) {
gl_Position = vec4(0.0);
vUV = vec2(0.0);
vIconType = -1;
return;
}
// Get atlas 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;
return;
}
// Lerped world position and size (same math as name.vert.glsl)
float elapsed = uTime - pd0.w;
float t = clamp(1.0 - exp(-uLerpSpeed * elapsed), 0.0, 1.0);
float wx = mix(pd0.x, pd1.x, t);
float wy = mix(pd0.y, pd1.y, t);
float ws = mix(pd0.z, pd1.z, t);
// Sizing pipeline (must match name.vert.glsl exactly)
float baseSize = max(1.0, floor(ws));
float nameSize = max(4.0, floor(baseSize * uNameScaleFactor));
float nameScale = min(baseSize * 0.25, uNameScaleCap);
float nameWorldScale = (nameSize * nameScale) / uFontSize;
// Zoom-based culling (same as name shader)
float cameraScale = length(vec2(uCamera[0][0], uCamera[1][0]));
float screenSize = nameWorldScale * uFontBase * cameraScale;
if (screenSize < uCullThreshold) {
gl_Position = vec4(0.0);
vUV = vec2(0.0);
vIconType = -1;
return;
}
float nameHalfWidth = pd3.w; // in font units (pre-scaled by nameWorldScale at runtime)
// 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
float flagWorldH = uFontBase * nameWorldScale * 1.2;
float flagWorldW = flagWorldH * (cellW / cellH);
// 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
float emojiWorldSize = uFontBase * nameWorldScale * 1.0;
// Position: centered above name
iconOrigin = vec2(
wx - emojiWorldSize * 0.5,
wy - uFontBase * nameWorldScale * uEmojiRowOffset
);
iconW = emojiWorldSize;
iconH = emojiWorldSize;
}
// Quad world position
vec2 worldPos = iconOrigin + aPos * vec2(iconW, iconH);
// Camera transform
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;
}
#version 300 es
precision highp float;
precision highp int;
// Unit quad vertex position [0,0]→[1,1]
layout(location = 0) in vec2 aPos;
// Data textures (shared with name shader)
uniform sampler2D uPlayerData; // PLAYER_DATA_COLS × MAX_PLAYERS, RGBA32F
// Uniforms
uniform mat3 uCamera;
uniform float uTime;
uniform float uLerpSpeed;
uniform float uCullThreshold;
uniform float uNameScaleFactor;
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 cell shape (fixed, matches FlagAtlasArray cell size)
uniform float uFlagCellW;
uniform float uFlagCellH;
// Emoji atlas layout
uniform float uEmojiCell; // texels per emoji cell (square)
uniform float uEmojiCols; // columns in emoji atlas
uniform float uEmojiAtlasW; // emoji atlas texture width
uniform float uEmojiAtlasH; // emoji atlas texture height
// Row offset (multiples of uFontBase * nameWorldScale)
uniform float uEmojiRowOffset;
out vec2 vUV;
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)
int playerIdx = gl_InstanceID / 2;
int iconType = gl_InstanceID - playerIdx * 2;
// Read player data
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); // 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/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;
}
// Lerped world position and size (same math as name.vert.glsl)
float elapsed = uTime - pd0.w;
float t = clamp(1.0 - exp(-uLerpSpeed * elapsed), 0.0, 1.0);
float wx = mix(pd0.x, pd1.x, t);
float wy = mix(pd0.y, pd1.y, t);
float ws = mix(pd0.z, pd1.z, t);
// Sizing pipeline (must match name.vert.glsl exactly)
float baseSize = max(1.0, floor(ws));
float nameSize = max(4.0, floor(baseSize * uNameScaleFactor));
float nameScale = min(baseSize * 0.25, uNameScaleCap);
float nameWorldScale = (nameSize * nameScale) / uFontSize;
// Zoom-based culling (same as name shader)
float cameraScale = length(vec2(uCamera[0][0], uCamera[1][0]));
float screenSize = nameWorldScale * uFontBase * cameraScale;
if (screenSize < uCullThreshold) {
gl_Position = vec4(0.0);
vUV = vec2(0.0);
vIconType = -1;
vFlagLayer = 0;
return;
}
float nameHalfWidth = pd3.w; // in font units (pre-scaled by nameWorldScale at runtime)
// Compute icon size and position based on type
float iconW, iconH;
vec2 iconOrigin;
if (iconType == 0) {
// 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 * (uFlagCellW / uFlagCellH);
iconOrigin = vec2(
wx - nameHalfWidth * nameWorldScale - flagWorldW,
wy - flagWorldH * 0.5
);
iconW = flagWorldW;
iconH = flagWorldH;
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;
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
vec2 worldPos = iconOrigin + aPos * vec2(iconW, iconH);
// Camera transform
vec3 clip = uCamera * vec3(worldPos, 1.0);
gl_Position = vec4(clip.xy, 0.0, 1.0);
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