Add a graphics setting to render names/troops in a bitmap Arial font

NamePass can now render player names and troop counts in an Arial bitmap font as an alternative to the overpass-bold MSDF font, toggled live via settings.name.classicFont (mirrors the structure-level font toggle).

The Arial atlas is built once at runtime with canvas 2D (there is no Arial asset to ship): each glyph is rasterized, its tight bbox found by a pixel scan, and the glyphs shelf-packed into a coverage atlas. It emits a ParsedAtlas with the same em size (48) and baseline (36) as the MSDF atlas, so it flows through the existing glyph-table/metrics/layout code and leaves name sizing, hit-testing, flag offsets and the icon/status passes unchanged. The name shader gains a uClassic branch that tints the coverage mask with the fill color (no outline, matching the old DOM-rendered names). On toggle, all name/troop lines are re-laid-out since glyph advances differ between the fonts.

Exposed as name.classicFont in GraphicsOverrides (default false = MSDF) with a Classic Names toggle in the graphics settings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Evan Pelle
2026-06-14 00:28:00 +00:00
parent 5be72db060
commit 0727c2cf4e
10 changed files with 370 additions and 31 deletions
+2
View File
@@ -516,6 +516,8 @@
"black": "Black",
"classic_icons_desc": "Lighter outline with near-black interior",
"classic_icons_label": "Classic icons",
"classic_names_desc": "Arial bitmap font for player names and troops (off uses the smooth font)",
"classic_names_label": "Classic names",
"classic_numbers_desc": "Pixel font for structure level numbers (off uses the smooth font)",
"classic_numbers_label": "Classic level numbers",
"colored": "Colored",
@@ -535,6 +535,14 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
this.patchName({ darkNames: !this.currentDarkNames() });
}
private currentClassicNames(): boolean {
return this.userSettings.graphicsOverrides().name?.classicFont ?? false;
}
private onToggleClassicNames() {
this.patchName({ classicFont: !this.currentClassicNames() });
}
private onResetClick() {
this.userSettings.setGraphicsOverrides({});
this.requestUpdate();
@@ -550,6 +558,7 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
const hoverGlowAlpha = this.currentHoverGlowAlpha();
const namesColored = !this.currentDarkNames();
const iconSize = this.currentIconSize();
const classicNames = this.currentClassicNames();
const classicIcons = this.currentClassicIcons();
const classicNumbers = this.currentClassicNumbers();
const highlightFill = this.currentHighlightFill();
@@ -800,6 +809,25 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click=${this.onToggleClassicNames}
>
<div class="flex-1">
<div class="font-medium">
${translateText("graphics_setting.classic_names_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("graphics_setting.classic_names_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${classicNames
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<div
class="px-3 py-1 text-xs font-semibold text-slate-400 uppercase tracking-wider mt-2"
>
@@ -7,6 +7,7 @@ export const GraphicsOverridesSchema = z
nameScaleFactor: z.number(),
cullThreshold: z.number(),
darkNames: z.boolean(),
classicFont: z.boolean(),
hoverFadeAlpha: z.number(),
hoverGlowWidth: z.number(),
hoverGlowAlpha: z.number(),
+2
View File
@@ -28,6 +28,8 @@ export function applyGraphicsOverrides(
if (overrides.structure?.iconSize !== undefined) {
settings.structure.iconSize = overrides.structure.iconSize;
}
// Classic names: Arial bitmap font for names/troops (default false = MSDF).
settings.name.classicFont = overrides.name?.classicFont ?? false;
if (overrides.structure?.classicIcons ?? true) {
// Classic look (default): lighter player-colored shape behind a darkened
// player-colored icon glyph (matching the old canvas renderer's
+2
View File
@@ -265,6 +265,8 @@ export interface RenderSettings {
hoverGlowWidth: number;
/** Peak opacity of the hover glow (0 disables it). */
hoverGlowAlpha: number;
/** true = Arial bitmap font for names/troops, false = overpass MSDF. */
classicFont: boolean;
};
fx: {
shockwaveRingWidth: number;
@@ -0,0 +1,184 @@
/**
* 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.
*
* Metrics are emitted with the SAME em size (48) and baseline (36) as the MSDF
* atlas, so name sizing, hit-testing, flag offsets and icon alignment are all
* unchanged — only the glyph shapes and advances differ. The returned shape is
* a ParsedAtlas, so it flows through buildGlyphTables / buildGlyphMetricsTex /
* layoutString exactly like the MSDF atlas.
*/
import type { BMChar, ParsedAtlas } from "./Types";
// Match the MSDF atlas em/base so all downstream sizing stays identical.
const EM = 48;
const BASE = 36;
// Bold to match the weight of the overpass-bold name style.
const FONT = `bold ${EM}px Arial, "Liberation Sans", sans-serif`;
const ATLAS_W = 1024;
const PAD = 2; // transparent gutter between packed glyphs
// 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;
w: number;
h: number;
xoffset: number;
yoffset: number;
// Where the tight bbox landed in the measuring canvas (to re-place it later).
srcMinX: number;
srcMinY: number;
}
export function generateArialBitmapAtlas(): {
atlas: ParsedAtlas;
canvas: HTMLCanvasElement;
} {
// --- measuring pass: rasterize each glyph, find its tight bbox ---
const CELL = 80; // > any 48px glyph incl. ascenders/descenders
const PEN_X = 8;
const PEN_Y = 56; // baseline inside the measuring cell
const tmp = document.createElement("canvas");
tmp.width = CELL;
tmp.height = CELL;
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,
xoffset: 0,
yoffset: 0,
srcMinX: 0,
srcMinY: 0,
});
continue;
}
measured.push({
code,
ch,
advance,
w: maxX - minX + 1,
h: maxY - minY + 1,
xoffset: minX - PEN_X, // glyph left relative to the pen
yoffset: BASE - (PEN_Y - minY), // glyph top relative to the line top
srcMinX: minX,
srcMinY: minY,
});
}
// --- shelf packing into a fixed-width atlas ---
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);
}
const atlas: ParsedAtlas = {
fontSize: EM,
base: BASE,
scaleW: ATLAS_W,
scaleH: ATLAS_H,
distanceRange: 0,
chars,
kernings: [],
};
return { atlas, canvas };
}
function toBMChar(m: Measured, ax: number, ay: number): BMChar {
return {
id: m.code,
char: m.ch,
width: m.w,
height: m.h,
xoffset: m.xoffset,
yoffset: m.yoffset,
xadvance: m.advance,
x: ax,
y: ay,
page: 0,
};
}
@@ -1,9 +1,16 @@
/**
* TextProgram — MSDF text rendering (player names + troop counts).
* TextProgram — text rendering for player names + troop counts.
*
* Owns: shader program, uniform locations, MSDF atlas texture (async loaded).
* Shared textures (glyphMetrics, cursor, strings, playerData) are passed in
* and bound at draw time but not owned/deleted by this class.
* 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.
*
* 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.
*/
import { assetUrl } from "src/core/AssetUrls";
@@ -28,14 +35,26 @@ export class TextProgram {
private program: WebGLProgram;
private textures: TextProgramTextures;
// Async-loaded MSDF atlas
// 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;
// Uniform locations
private uCamera: WebGLUniformLocation;
private uTime: WebGLUniformLocation;
private uDistRange: WebGLUniformLocation;
private uAtlasScaleW: WebGLUniformLocation;
private uAtlasScaleH: WebGLUniformLocation;
private uClassic: WebGLUniformLocation;
private uLerpSpeed: WebGLUniformLocation;
private uCullThreshold: WebGLUniformLocation;
private uNameScaleFactor: WebGLUniformLocation;
@@ -52,8 +71,6 @@ export class TextProgram {
private uHoverGlowWidth: WebGLUniformLocation;
private uHoverGlowAlpha: WebGLUniformLocation;
private distanceRange: number;
constructor(
gl: WebGL2RenderingContext,
atlas: ParsedAtlas,
@@ -62,6 +79,8 @@ export class TextProgram {
this.gl = gl;
this.textures = textures;
this.distanceRange = atlas.distanceRange;
this.msdfScaleW = atlas.scaleW;
this.msdfScaleH = atlas.scaleH;
this.program = createProgram(
gl,
@@ -77,25 +96,20 @@ export class TextProgram {
gl.uniform1i(gl.getUniformLocation(this.program, "uStrings"), 3);
gl.uniform1i(gl.getUniformLocation(this.program, "uPlayerData"), 4);
// Static uniforms
// 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, "uAtlasScaleW")!,
atlas.scaleW,
);
gl.uniform1f(
gl.getUniformLocation(this.program, "uAtlasScaleH")!,
atlas.scaleH,
);
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.uAtlasScaleW = gl.getUniformLocation(this.program, "uAtlasScaleW")!;
this.uAtlasScaleH = gl.getUniformLocation(this.program, "uAtlasScaleH")!;
this.uClassic = gl.getUniformLocation(this.program, "uClassic")!;
this.uLerpSpeed = gl.getUniformLocation(this.program, "uLerpSpeed")!;
this.uCullThreshold = gl.getUniformLocation(
this.program,
@@ -142,8 +156,30 @@ export class TextProgram {
this.loadAtlas();
}
get ready(): boolean {
return this.atlasReady;
/** True when the atlas for the requested font is uploaded and drawable. */
isReady(classic: boolean): boolean {
return classic ? this.arialAtlasTex !== null : this.atlasReady;
}
/** Install the runtime-built Arial bitmap atlas (coverage mask) + metrics. */
setArialFont(
canvas: HTMLCanvasElement,
metricsTex: WebGLTexture,
scaleW: number,
scaleH: 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;
this.arialScaleH = scaleH;
}
private loadAtlas(): void {
@@ -172,16 +208,28 @@ export class TextProgram {
ambient: number,
highlightOwnerID: number,
fadeOwnerID: number,
classic: boolean,
): void {
if (!this.atlasReady) return;
if (!this.isReady(classic)) return;
const gl = this.gl;
const ns = settings.name;
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, this.distanceRange);
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.uLerpSpeed, ns.lerpSpeed);
gl.uniform1f(this.uCullThreshold, ns.cullThreshold);
gl.uniform1f(this.uNameScaleFactor, ns.nameScaleFactor);
@@ -202,9 +250,9 @@ export class TextProgram {
gl.uniform1f(this.uHoverGlowAlpha, ns.hoverGlowAlpha);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.atlasTex!);
gl.bindTexture(gl.TEXTURE_2D, atlasTex);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.textures.glyphMetrics);
gl.bindTexture(gl.TEXTURE_2D, metricsTex);
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, this.textures.cursor);
gl.activeTexture(gl.TEXTURE3);
@@ -225,5 +273,6 @@ export class TextProgram {
const gl = this.gl;
gl.deleteProgram(this.program);
if (this.atlasTex) gl.deleteTexture(this.atlasTex);
if (this.arialAtlasTex) gl.deleteTexture(this.arialAtlasTex);
}
}
+64 -3
View File
@@ -29,6 +29,7 @@ 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,
@@ -49,7 +50,7 @@ import { StatusIconProgram } from "./StatusIconProgram";
import { layoutString } from "./TextLayout";
import { TextProgram } from "./TextProgram";
import type { PlayerSlot } from "./Types";
import { LINES_PER_PLAYER, MAX_CHARS } from "./Types";
import { CHAR_RANGE, 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;
@@ -73,11 +74,19 @@ export class NamePass {
private statusIconProgram: StatusIconProgram;
private debugProgram: DebugProgram;
// Atlas + glyph data
// Atlas + glyph data. `glyph`/`kernTable` point at the active font; the MSDF
// and Arial tables are kept so layout can be recomputed when the font toggles.
private glyph: GlyphTables;
private kernTable: Int8Array;
private fontSize: number;
private fontBase: number;
private msdfGlyph: GlyphTables;
private msdfKern: Int8Array;
private arialGlyph: GlyphTables;
private arialKern: Int8Array;
private arialMetricsTex: WebGLTexture;
// Which font the cursor buffers are currently laid out for.
private classicFont = false;
// Player management
private playerByID: Map<string, PlayerStatic>;
@@ -130,6 +139,8 @@ export class NamePass {
this.fontBase = atlas.base;
this.glyph = buildGlyphTables(atlas.chars);
this.kernTable = buildKernTable(atlas.kernings);
this.msdfGlyph = this.glyph;
this.msdfKern = this.kernTable;
this.emojiCharToIndex = buildEmojiLookup();
// Runtime flag-image manager (TEXTURE_2D_ARRAY of player flags, fetched
@@ -204,6 +215,25 @@ export class NamePass {
this.playerDataTex,
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,
);
this.classicFont = settings.name.classicFont;
if (this.classicFont) {
this.glyph = this.arialGlyph;
this.kernTable = this.arialKern;
}
}
// -------------------------------------------------------------------------
@@ -643,8 +673,37 @@ export class NamePass {
return 0;
}
/**
* 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.
*/
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;
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);
}
}
draw(cameraMatrix: Float32Array, ambient: number): void {
if (!this.textProgram.ready) return;
this.syncFont();
if (!this.textProgram.isReady(this.classicFont)) return;
if (this.slots.size === 0) return;
const gl = this.gl;
@@ -704,6 +763,7 @@ export class NamePass {
ambient,
this.highlightOwnerID,
fadeOwnerID,
this.classicFont,
);
this.statusIconProgram.draw(
cameraMatrix,
@@ -730,6 +790,7 @@ export class NamePass {
this.statusIconProgram.dispose();
this.debugProgram.dispose();
gl.deleteTexture(this.glyphMetricsTex);
gl.deleteTexture(this.arialMetricsTex);
gl.deleteTexture(this.cursorTex);
gl.deleteTexture(this.stringTex);
gl.deleteTexture(this.playerDataTex);
+2 -1
View File
@@ -217,7 +217,8 @@
"statusRowOffset": 1.4,
"hoverFadeAlpha": 0.5,
"hoverGlowWidth": 5,
"hoverGlowAlpha": 0.75
"hoverGlowAlpha": 0.75,
"classicFont": false
},
"fx": {
"shockwaveRingWidth": 0.04,
@@ -10,6 +10,7 @@ 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
@@ -25,16 +26,24 @@ 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).
if (uClassic == 1) {
float coverage = texture(uAtlas, vUV).a;
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);