mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 14:00:54 +00:00
ac45b0da64
Rasterize the Arial atlas 2x finer than the em metrics so large names have straighter, cleaner edges (the 48px atlas wobbled under the screen-space edge sharpening). The name shader now derives glyph UVs from glyph-size / atlas-pixels-per-em, so metrics stay in 48-em space and flag/icon sizing is unchanged; for the MSDF atlas this is identical to the old precomputed UV. Also lighten the coverage erosion so strokes are a bit thicker. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
284 lines
10 KiB
TypeScript
284 lines
10 KiB
TypeScript
/**
|
||
* TextProgram — text rendering for 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.
|
||
*
|
||
* 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";
|
||
import type { RenderSettings } from "../../RenderSettings";
|
||
import nameFragSrc from "../../shaders/name/name.frag.glsl?raw";
|
||
import nameVertSrc from "../../shaders/name/name.vert.glsl?raw";
|
||
import { createProgram, shaderSrc } from "../../utils/GlUtils";
|
||
import type { ParsedAtlas } from "./Types";
|
||
import { LINES_PER_PLAYER, MAX_CHARS } from "./Types";
|
||
|
||
const atlasUrl = assetUrl("atlases/msdf-atlas.png");
|
||
|
||
export interface TextProgramTextures {
|
||
glyphMetrics: WebGLTexture;
|
||
cursor: WebGLTexture;
|
||
strings: WebGLTexture;
|
||
playerData: WebGLTexture;
|
||
}
|
||
|
||
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;
|
||
|
||
// 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;
|
||
private uNameScaleCap: WebGLUniformLocation;
|
||
private uTroopSizeMultiplier: WebGLUniformLocation;
|
||
private uHighlightOwnerID: WebGLUniformLocation;
|
||
private uFadeOwnerID: WebGLUniformLocation;
|
||
private uHoverFadeAlpha: WebGLUniformLocation;
|
||
private uOutlineWidth: WebGLUniformLocation;
|
||
private uNightAmbient: WebGLUniformLocation;
|
||
private uOutlineColor: WebGLUniformLocation;
|
||
private uOutlineUsePlayerColor: WebGLUniformLocation;
|
||
private uFillUsePlayerColor: WebGLUniformLocation;
|
||
private uHoverGlowWidth: WebGLUniformLocation;
|
||
private uHoverGlowAlpha: WebGLUniformLocation;
|
||
|
||
constructor(
|
||
gl: WebGL2RenderingContext,
|
||
atlas: ParsedAtlas,
|
||
textures: TextProgramTextures,
|
||
) {
|
||
this.gl = gl;
|
||
this.textures = textures;
|
||
this.distanceRange = atlas.distanceRange;
|
||
this.msdfScaleW = atlas.scaleW;
|
||
this.msdfScaleH = atlas.scaleH;
|
||
|
||
this.program = createProgram(
|
||
gl,
|
||
shaderSrc(nameVertSrc, { MAX_CHARS, LINES_PER_PLAYER }),
|
||
nameFragSrc,
|
||
);
|
||
|
||
// Texture unit bindings
|
||
gl.useProgram(this.program);
|
||
gl.uniform1i(gl.getUniformLocation(this.program, "uAtlas"), 0);
|
||
gl.uniform1i(gl.getUniformLocation(this.program, "uGlyphMetrics"), 1);
|
||
gl.uniform1i(gl.getUniformLocation(this.program, "uCursorX"), 2);
|
||
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.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,
|
||
"uCullThreshold",
|
||
)!;
|
||
this.uNameScaleFactor = gl.getUniformLocation(
|
||
this.program,
|
||
"uNameScaleFactor",
|
||
)!;
|
||
this.uNameScaleCap = gl.getUniformLocation(this.program, "uNameScaleCap")!;
|
||
this.uTroopSizeMultiplier = gl.getUniformLocation(
|
||
this.program,
|
||
"uTroopSizeMultiplier",
|
||
)!;
|
||
this.uHighlightOwnerID = gl.getUniformLocation(
|
||
this.program,
|
||
"uHighlightOwnerID",
|
||
)!;
|
||
this.uFadeOwnerID = gl.getUniformLocation(this.program, "uFadeOwnerID")!;
|
||
this.uHoverFadeAlpha = gl.getUniformLocation(
|
||
this.program,
|
||
"uHoverFadeAlpha",
|
||
)!;
|
||
this.uOutlineWidth = gl.getUniformLocation(this.program, "uOutlineWidth")!;
|
||
this.uNightAmbient = gl.getUniformLocation(this.program, "uNightAmbient")!;
|
||
this.uOutlineColor = gl.getUniformLocation(this.program, "uOutlineColor")!;
|
||
this.uOutlineUsePlayerColor = gl.getUniformLocation(
|
||
this.program,
|
||
"uOutlineUsePlayerColor",
|
||
)!;
|
||
this.uFillUsePlayerColor = gl.getUniformLocation(
|
||
this.program,
|
||
"uFillUsePlayerColor",
|
||
)!;
|
||
this.uHoverGlowWidth = gl.getUniformLocation(
|
||
this.program,
|
||
"uHoverGlowWidth",
|
||
)!;
|
||
this.uHoverGlowAlpha = gl.getUniformLocation(
|
||
this.program,
|
||
"uHoverGlowAlpha",
|
||
)!;
|
||
|
||
this.loadAtlas();
|
||
}
|
||
|
||
/** 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.
|
||
* 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;
|
||
}
|
||
|
||
private loadAtlas(): void {
|
||
const gl = this.gl;
|
||
const img = new Image();
|
||
img.crossOrigin = "anonymous";
|
||
img.onload = () => {
|
||
const tex = gl.createTexture()!;
|
||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
||
this.atlasTex = tex;
|
||
this.atlasReady = true;
|
||
};
|
||
img.src = atlasUrl;
|
||
}
|
||
|
||
draw(
|
||
cameraMatrix: Float32Array,
|
||
settings: RenderSettings,
|
||
vao: WebGLVertexArrayObject,
|
||
maxPlayers: number,
|
||
ambient: number,
|
||
highlightOwnerID: number,
|
||
fadeOwnerID: number,
|
||
classic: boolean,
|
||
): void {
|
||
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, 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);
|
||
gl.uniform1f(this.uNameScaleCap, ns.nameScaleCap);
|
||
gl.uniform1f(this.uTroopSizeMultiplier, ns.troopSizeMultiplier);
|
||
gl.uniform1f(this.uHighlightOwnerID, highlightOwnerID);
|
||
gl.uniform1f(this.uFadeOwnerID, fadeOwnerID);
|
||
gl.uniform1f(this.uHoverFadeAlpha, ns.hoverFadeAlpha);
|
||
gl.uniform1f(this.uOutlineWidth, ns.outlineWidth);
|
||
gl.uniform1f(this.uNightAmbient, ambient);
|
||
gl.uniform3f(this.uOutlineColor, ns.outlineR, ns.outlineG, ns.outlineB);
|
||
gl.uniform1f(
|
||
this.uOutlineUsePlayerColor,
|
||
ns.outlineUsePlayerColor ? 1.0 : 0.0,
|
||
);
|
||
gl.uniform1f(this.uFillUsePlayerColor, ns.fillUsePlayerColor ? 1.0 : 0.0);
|
||
gl.uniform1f(this.uHoverGlowWidth, ns.hoverGlowWidth);
|
||
gl.uniform1f(this.uHoverGlowAlpha, ns.hoverGlowAlpha);
|
||
|
||
gl.activeTexture(gl.TEXTURE0);
|
||
gl.bindTexture(gl.TEXTURE_2D, atlasTex);
|
||
gl.activeTexture(gl.TEXTURE1);
|
||
gl.bindTexture(gl.TEXTURE_2D, metricsTex);
|
||
gl.activeTexture(gl.TEXTURE2);
|
||
gl.bindTexture(gl.TEXTURE_2D, this.textures.cursor);
|
||
gl.activeTexture(gl.TEXTURE3);
|
||
gl.bindTexture(gl.TEXTURE_2D, this.textures.strings);
|
||
gl.activeTexture(gl.TEXTURE4);
|
||
gl.bindTexture(gl.TEXTURE_2D, this.textures.playerData);
|
||
|
||
gl.bindVertexArray(vao);
|
||
gl.drawArraysInstanced(
|
||
gl.TRIANGLES,
|
||
0,
|
||
6,
|
||
maxPlayers * LINES_PER_PLAYER * MAX_CHARS,
|
||
);
|
||
}
|
||
|
||
dispose(): void {
|
||
const gl = this.gl;
|
||
gl.deleteProgram(this.program);
|
||
if (this.atlasTex) gl.deleteTexture(this.atlasTex);
|
||
if (this.arialAtlasTex) gl.deleteTexture(this.arialAtlasTex);
|
||
}
|
||
}
|