Files
OpenFrontIO/src/client/render/gl/passes/name-pass/IconProgram.ts
T
Evan 7137347b7d Fade player names under the cursor, with a graphics setting to tune it (#4221)
## Description:

Player name plates can block the view of what's underneath them
(structures, units, terrain). This PR fades the entire name plate —
name, troop count, flag, and emoji/status row — to 25% opacity while the
cursor is over it, so you can see and click what's behind it.

**How it works:**

- `HoverHighlightController` pushes the cursor's world position into the
renderer on mouse move.
- `NamePass` hit-tests the cursor against each player's name plate
bounds on the CPU (mirroring the lerp/sizing math in `name.vert.glsl`)
and passes the matched player's ID to the text, icon, and status-icon
programs, which apply the alpha multiplier in their shaders.

**Graphics setting:**

- New "Name opacity under cursor" slider in the Graphics Settings modal
(Name Labels section), range 0–1, default 0.25. Setting it to 1 disables
the fade entirely.
- Wired through the existing `GraphicsOverrides` pipeline: changes apply
live and are cleared by "Reset to defaults".
- Tuning knob exposed as `name.hoverFadeAlpha` in `render-settings.json`
and the debug GUI.

## Please complete the following:

- [ ] 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
- [ ] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
2026-06-11 09:25:13 -07:00

187 lines
6.1 KiB
TypeScript

/**
* IconProgram — instanced flag + emoji icons beside player names.
*
* 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 { 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");
// 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 emojiAtlasTex: WebGLTexture | null = null;
private emojiReady = false;
// Dynamic uniform locations
private uCamera: WebGLUniformLocation;
private uTime: WebGLUniformLocation;
private uLerpSpeed: WebGLUniformLocation;
private uCullThreshold: WebGLUniformLocation;
private uNameScaleFactor: WebGLUniformLocation;
private uNameScaleCap: WebGLUniformLocation;
private uEmojiRowOffset: WebGLUniformLocation;
private uFadeOwnerID: WebGLUniformLocation;
private uHoverFadeAlpha: WebGLUniformLocation;
constructor(
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);
gl.useProgram(this.program);
// Texture unit bindings
gl.uniform1i(gl.getUniformLocation(this.program, "uPlayerData"), 0);
gl.uniform1i(gl.getUniformLocation(this.program, "uFlagAtlas"), 1);
gl.uniform1i(gl.getUniformLocation(this.program, "uEmojiAtlas"), 2);
// Static uniforms from atlas metadata
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")!,
FLAG_CELL_W,
);
gl.uniform1f(
gl.getUniformLocation(this.program, "uFlagCellH")!,
FLAG_CELL_H,
);
gl.uniform1f(
gl.getUniformLocation(this.program, "uEmojiCell")!,
em.cellSize,
);
gl.uniform1f(gl.getUniformLocation(this.program, "uEmojiCols")!, em.cols);
gl.uniform1f(
gl.getUniformLocation(this.program, "uEmojiAtlasW")!,
em.width,
);
gl.uniform1f(
gl.getUniformLocation(this.program, "uEmojiAtlasH")!,
em.height,
);
// 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.uEmojiRowOffset = gl.getUniformLocation(
this.program,
"uEmojiRowOffset",
)!;
this.uFadeOwnerID = gl.getUniformLocation(this.program, "uFadeOwnerID")!;
this.uHoverFadeAlpha = gl.getUniformLocation(
this.program,
"uHoverFadeAlpha",
)!;
this.loadEmojiAtlas();
}
get ready(): boolean {
return this.emojiReady;
}
private loadEmojiAtlas(): 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.emojiAtlasTex = tex;
this.emojiReady = true;
};
img.src = emojiAtlasUrl;
}
draw(
cameraMatrix: Float32Array,
settings: RenderSettings,
vao: WebGLVertexArrayObject,
fadeOwnerID: number,
): void {
if (!this.emojiReady) 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.uEmojiRowOffset, ns.emojiRowOffset);
gl.uniform1f(this.uFadeOwnerID, fadeOwnerID);
gl.uniform1f(this.uHoverFadeAlpha, ns.hoverFadeAlpha);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.playerDataTex);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.flagAtlas.texture);
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, this.emojiAtlasTex!);
gl.bindVertexArray(vao);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.maxPlayers * 2);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
if (this.emojiAtlasTex) gl.deleteTexture(this.emojiAtlasTex);
}
}