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