Files
OpenFrontIO/src/client/render/gl/passes/name-pass/StatusIconProgram.ts
T
Evan cb0d79ed6d Add black outline to alliance icon for terrain contrast (#4353)
## Problem

The green alliance icon above player names blends into similarly-colored
terrain — most notably irradiated land, which is the same green — making
it hard to spot allied players.

## Fix

Add a configurable dark outline to the alliance status icon, rendered in
the status-icon shader (the icons come from a pre-baked atlas with no
regeneration script, so this is done in-shader rather than by editing
the PNG).

- **Outline**: an alpha dilation gated to the alliance icon (slot 3).
8-direction sampling of the icon's alpha builds a black halo around its
silhouette; interior pixels and all other status icons are untouched.
- **No clipping**: the alliance icon's quad is grown outward into the
atlas cell's existing transparent padding so the halo isn't clipped at
the quad edge. The icon's on-screen size and position are unchanged; 8px
of the cell's 16px mipmap-safety padding is preserved.
- **Drain stays aligned**: the alliance-expiry drain effect's cut line
and faded-icon UVs are remapped into the expanded quad space so the
animation still lines up.
- **Tunable**: width is driven by `name.statusOutlineWidth` in
`render-settings.json` (default 6 texels; 0 disables), with a matching
"Status Outline Width" slider in the debug GUI.

## Testing

`tsc` and `eslint` pass. Verified in-game: the handshake now reads
clearly against irradiated terrain, with the outline rendering fully (no
edge clipping) and the drain animation still aligned.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:17:14 -07:00

195 lines
6.3 KiB
TypeScript

/**
* StatusIconProgram — instanced status icons above player names.
*
* Renders up to 8 status icons per player (crown, traitor, disconnected,
* alliance, alliance request, target, embargo, nuke). Each instance reads
* individual float flags from pd5/pd6 to decide whether to draw.
*
* Owns: shader program, uniform locations, status atlas texture.
* The shared playerDataTex is passed in but not owned/deleted.
*/
import statusAtlasMeta from "resources/atlases/status-atlas-meta.json";
import { assetUrl } from "src/core/AssetUrls";
import type { RenderSettings } from "../../RenderSettings";
import statusFragSrc from "../../shaders/name/status-icon.frag.glsl?raw";
import statusVertSrc from "../../shaders/name/status-icon.vert.glsl?raw";
import { createProgram } from "../../utils/GlUtils";
import type { ParsedAtlas } from "./Types";
const statusAtlasUrl = assetUrl("atlases/status-atlas.png");
const MAX_STATUS_ICONS = 8;
export class StatusIconProgram {
private gl: WebGL2RenderingContext;
private program: WebGLProgram;
private playerDataTex: WebGLTexture;
private maxPlayers: number;
private statusAtlasTex: WebGLTexture | null = null;
private atlasReady = false;
// Dynamic uniform locations
private uCamera: WebGLUniformLocation;
private uTime: WebGLUniformLocation;
private uLerpSpeed: WebGLUniformLocation;
private uCullThreshold: WebGLUniformLocation;
private uNameScaleFactor: WebGLUniformLocation;
private uNameScaleCap: WebGLUniformLocation;
private uStatusRowOffset: WebGLUniformLocation;
private uFadeOwnerID: WebGLUniformLocation;
private uHoverFadeAlpha: WebGLUniformLocation;
private uStatusOutlinePx: WebGLUniformLocation;
constructor(
gl: WebGL2RenderingContext,
atlas: ParsedAtlas,
playerDataTex: WebGLTexture,
maxPlayers: number,
allianceFlashWindowTicks: number,
) {
this.gl = gl;
this.playerDataTex = playerDataTex;
this.maxPlayers = maxPlayers;
this.program = createProgram(gl, statusVertSrc, statusFragSrc);
gl.useProgram(this.program);
// Texture unit bindings
gl.uniform1i(gl.getUniformLocation(this.program, "uPlayerData"), 0);
gl.uniform1i(gl.getUniformLocation(this.program, "uStatusAtlas"), 1);
// Static uniforms from atlas metadata
const sm = statusAtlasMeta 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, "uStatusCell")!,
sm.cellSize,
);
gl.uniform1f(gl.getUniformLocation(this.program, "uStatusCols")!, sm.cols);
gl.uniform1f(
gl.getUniformLocation(this.program, "uStatusAtlasW")!,
sm.width,
);
gl.uniform1f(
gl.getUniformLocation(this.program, "uStatusAtlasH")!,
sm.height,
);
gl.uniform1f(
gl.getUniformLocation(this.program, "uStatusPad")!,
sm.pad ?? 0,
);
// Texel size for the outline dilation sampling (static).
gl.uniform2f(
gl.getUniformLocation(this.program, "uStatusTexel")!,
1 / sm.width,
1 / sm.height,
);
// Flash window matches the alliance renewal prompt (10 ticks/sec)
gl.uniform1f(
gl.getUniformLocation(this.program, "uAllianceFlashWindowSec")!,
allianceFlashWindowTicks / 10,
);
// Dynamic uniform locations
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uTime = gl.getUniformLocation(this.program, "uTime")!;
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.uStatusRowOffset = gl.getUniformLocation(
this.program,
"uStatusRowOffset",
)!;
this.uFadeOwnerID = gl.getUniformLocation(this.program, "uFadeOwnerID")!;
this.uHoverFadeAlpha = gl.getUniformLocation(
this.program,
"uHoverFadeAlpha",
)!;
this.uStatusOutlinePx = gl.getUniformLocation(
this.program,
"uStatusOutlinePx",
)!;
this.loadAtlas();
}
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_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.statusAtlasTex = tex;
this.atlasReady = true;
};
img.src = statusAtlasUrl;
}
draw(
cameraMatrix: Float32Array,
settings: RenderSettings,
vao: WebGLVertexArrayObject,
fadeOwnerID: number,
): void {
if (!this.atlasReady) return;
const gl = this.gl;
const ns = settings.name;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform1f(this.uTime, performance.now() / 1000);
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.uStatusRowOffset, ns.statusRowOffset);
gl.uniform1f(this.uFadeOwnerID, fadeOwnerID);
gl.uniform1f(this.uHoverFadeAlpha, ns.hoverFadeAlpha);
gl.uniform1f(this.uStatusOutlinePx, ns.statusOutlineWidth);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.playerDataTex);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.statusAtlasTex!);
gl.bindVertexArray(vao);
gl.drawArraysInstanced(
gl.TRIANGLES,
0,
6,
this.maxPlayers * MAX_STATUS_ICONS,
);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
if (this.statusAtlasTex) gl.deleteTexture(this.statusAtlasTex);
}
}