From 08b87156674a59aa8c76b095b3bf24ed8be07c30 Mon Sep 17 00:00:00 2001 From: Evan Date: Fri, 19 Jun 2026 20:16:45 -0700 Subject: [PATCH] Add black outline to alliance icon for terrain contrast (#4353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- src/client/render/gl/RenderSettings.ts | 2 ++ src/client/render/gl/debug/Layout.ts | 9 +++++ .../gl/passes/name-pass/StatusIconProgram.ts | 12 +++++++ src/client/render/gl/render-settings.json | 1 + .../gl/shaders/name/status-icon.frag.glsl | 29 ++++++++++++++-- .../gl/shaders/name/status-icon.vert.glsl | 33 ++++++++++++++++--- 6 files changed, 79 insertions(+), 7 deletions(-) diff --git a/src/client/render/gl/RenderSettings.ts b/src/client/render/gl/RenderSettings.ts index d301f1e2d..ca78b5e94 100644 --- a/src/client/render/gl/RenderSettings.ts +++ b/src/client/render/gl/RenderSettings.ts @@ -260,6 +260,8 @@ export interface RenderSettings { nameShadeBot: number; emojiRowOffset: number; statusRowOffset: number; + /** Dark outline radius (atlas texels) drawn behind the alliance icon; 0 = off. */ + statusOutlineWidth: number; /** Alpha multiplier applied to a name while the cursor is over it. */ hoverFadeAlpha: number; /** White glow behind the hovered player's name: px past the outline. */ diff --git a/src/client/render/gl/debug/Layout.ts b/src/client/render/gl/debug/Layout.ts index eefaaaa65..38b40e8b2 100644 --- a/src/client/render/gl/debug/Layout.ts +++ b/src/client/render/gl/debug/Layout.ts @@ -355,6 +355,15 @@ export function buildTree(s: RenderSettings, d: RenderSettings): DebugNode[] { toggle(s.name, "fillUsePlayerColor", d.name, "Fill = Player Color"), slider(s.name, "emojiRowOffset", d.name, 0, 5, 0.1, "Emoji Row Offset"), slider(s.name, "statusRowOffset", d.name, 0, 5, 0.1, "Status Row Offset"), + slider( + s.name, + "statusOutlineWidth", + d.name, + 0, + 16, + 0.5, + "Status Outline Width", + ), slider(s.name, "hoverFadeAlpha", d.name, 0, 1, 0.05, "Hover Fade Alpha"), slider(s.name, "hoverGlowWidth", d.name, 0, 8, 0.25, "Hover Glow Width"), slider(s.name, "hoverGlowAlpha", d.name, 0, 1, 0.05, "Hover Glow Alpha"), diff --git a/src/client/render/gl/passes/name-pass/StatusIconProgram.ts b/src/client/render/gl/passes/name-pass/StatusIconProgram.ts index 4fced34dd..b4c332483 100644 --- a/src/client/render/gl/passes/name-pass/StatusIconProgram.ts +++ b/src/client/render/gl/passes/name-pass/StatusIconProgram.ts @@ -40,6 +40,7 @@ export class StatusIconProgram { private uStatusRowOffset: WebGLUniformLocation; private uFadeOwnerID: WebGLUniformLocation; private uHoverFadeAlpha: WebGLUniformLocation; + private uStatusOutlinePx: WebGLUniformLocation; constructor( gl: WebGL2RenderingContext, @@ -83,6 +84,12 @@ export class StatusIconProgram { 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")!, @@ -111,6 +118,10 @@ export class StatusIconProgram { this.program, "uHoverFadeAlpha", )!; + this.uStatusOutlinePx = gl.getUniformLocation( + this.program, + "uStatusOutlinePx", + )!; this.loadAtlas(); } @@ -159,6 +170,7 @@ export class StatusIconProgram { 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); diff --git a/src/client/render/gl/render-settings.json b/src/client/render/gl/render-settings.json index 4c3e0eb39..b0de4a0eb 100644 --- a/src/client/render/gl/render-settings.json +++ b/src/client/render/gl/render-settings.json @@ -216,6 +216,7 @@ "nameShadeBot": 0.4, "emojiRowOffset": 1.4, "statusRowOffset": 1.4, + "statusOutlineWidth": 6, "hoverFadeAlpha": 0.5, "hoverGlowWidth": 5, "hoverGlowAlpha": 0.75 diff --git a/src/client/render/gl/shaders/name/status-icon.frag.glsl b/src/client/render/gl/shaders/name/status-icon.frag.glsl index d4e782934..14f004d6a 100644 --- a/src/client/render/gl/shaders/name/status-icon.frag.glsl +++ b/src/client/render/gl/shaders/name/status-icon.frag.glsl @@ -2,6 +2,8 @@ precision highp float; uniform sampler2D uStatusAtlas; +uniform vec2 uStatusTexel; // 1/atlasW, 1/atlasH +uniform float uStatusOutlinePx; // outline radius in atlas texels (0 = off) in vec2 vUV; in vec2 vLocalUV; @@ -10,10 +12,18 @@ flat in float vAllianceFraction; flat in vec2 vFadedUV0; flat in vec2 vFadedUV1; flat in float vFlashAlpha; +flat in float vOutline; // 1.0 = draw a dark outline behind this icon in float vHoverAlpha; out vec4 fragColor; +// 8 unit directions for the outline dilation sample ring. +const vec2 kRing[8] = vec2[8]( + vec2(1.0, 0.0), vec2(-1.0, 0.0), vec2(0.0, 1.0), vec2(0.0, -1.0), + vec2(0.707, 0.707), vec2(-0.707, 0.707), + vec2(0.707, -0.707), vec2(-0.707, -0.707) +); + void main() { if (vDiscard != 0) discard; @@ -33,8 +43,23 @@ void main() { texel = vLocalUV.y < topCut ? fadedTexel : texel; } - // Traitor flash: modulate alpha for urgency pulse - texel.a *= vFlashAlpha * vHoverAlpha; + // Traitor flash + hover fade: modulate alpha + float fade = vFlashAlpha * vHoverAlpha; + texel.a *= fade; + + // Dark outline: dilate the icon's alpha so it stays legible over terrain of a + // similar color (the green alliance icon vs. irradiated land). Sampling the + // padded atlas cell never reaches a neighbouring icon. + if (vOutline > 0.5 && uStatusOutlinePx > 0.0) { + float ring = 0.0; + vec2 sampleStep = uStatusTexel * uStatusOutlinePx; + for (int i = 0; i < 8; i++) { + ring = max(ring, texture(uStatusAtlas, vUV + kRing[i] * sampleStep).a); + } + ring *= fade; + float outlineA = ring * (1.0 - texel.a); + texel = vec4(mix(vec3(0.0), texel.rgb, texel.a), max(texel.a, outlineA)); + } if (texel.a < 0.01) discard; fragColor = texel; diff --git a/src/client/render/gl/shaders/name/status-icon.vert.glsl b/src/client/render/gl/shaders/name/status-icon.vert.glsl index 6d5bccb7d..7b2dc6cd6 100644 --- a/src/client/render/gl/shaders/name/status-icon.vert.glsl +++ b/src/client/render/gl/shaders/name/status-icon.vert.glsl @@ -24,6 +24,7 @@ uniform float uStatusCols; // columns in atlas uniform float uStatusAtlasW; // atlas texture width uniform float uStatusAtlasH; // atlas texture height uniform float uStatusPad; // transparent padding in texels per side +uniform float uStatusOutlinePx; // dark-outline radius in atlas texels (0 = off) // Configurable layout uniform float uStatusRowOffset; // row Y offset (multiples of uFontBase * nameWorldScale) @@ -39,6 +40,7 @@ flat out float vAllianceFraction; // 0 = no drain effect, >0 = active drain flat out vec2 vFadedUV0; // top-left UV of faded alliance cell flat out vec2 vFadedUV1; // bottom-right UV of faded alliance cell flat out float vFlashAlpha; // traitor flash opacity (1.0 = fully visible) +flat out float vOutline; // 1.0 = alliance icon, draw a dark outline out float vHoverAlpha; // Status flag float array — indexed by icon slot. @@ -103,6 +105,7 @@ void main() { vFadedUV0 = vec2(0.0); vFadedUV1 = vec2(0.0); vFlashAlpha = 1.0; + vOutline = 0.0; vHoverAlpha = 1.0; return; } @@ -120,6 +123,7 @@ void main() { vFadedUV0 = vec2(0.0); vFadedUV1 = vec2(0.0); vFlashAlpha = 1.0; + vOutline = 0.0; vHoverAlpha = 1.0; return; } @@ -149,6 +153,7 @@ void main() { vFadedUV0 = vec2(0.0); vFadedUV1 = vec2(0.0); vFlashAlpha = 1.0; + vOutline = 0.0; vHoverAlpha = 1.0; return; } @@ -180,23 +185,41 @@ void main() { atlasIdx = (pd7.x > 0.5) ? 7 : 8; } + // Only the alliance icon (slot 3) gets the dark outline. + vOutline = (iconSlot == 3) ? 1.0 : 0.0; + // Fade the status row along with the rest of the name plate when the cursor // is over any part of it. Hit test runs on the CPU (NamePass). vHoverAlpha = (uFadeOwnerID > 0.0 && pd4.z == uFadeOwnerID) ? uHoverFadeAlpha : 1.0; - // Quad world position - vec2 iconOrigin = vec2(iconX, iconY); - vec2 worldPos = iconOrigin + aPos * vec2(iconWorldSize, iconWorldSize); + // Dark-outline margin: grow the alliance icon's quad outward into the cell's + // transparent padding so the outline halo isn't clipped at the quad edge. + // The icon content keeps its size; only the quad's bounding box grows. Other + // icons keep marginWorld = 0 and render pixel-identically. + float iconTexels = uStatusCell - 2.0 * uStatusPad; + float marginTex = (vOutline > 0.5 && uStatusOutlinePx > 0.0) + ? min(uStatusPad - 2.0, uStatusOutlinePx + 2.0) + : 0.0; + float marginWorld = marginTex * (iconWorldSize / iconTexels); + + // Quad world position (expanded by the outline margin, centred on the icon) + vec2 iconOrigin = vec2(iconX, iconY) - vec2(marginWorld); + float quadSize = iconWorldSize + 2.0 * marginWorld; + vec2 worldPos = iconOrigin + aPos * vec2(quadSize, quadSize); // Camera transform vec3 clip = uCamera * vec3(worldPos, 1.0); gl_Position = vec4(clip.xy, 0.0, 1.0); + // vLocalUV in icon-content space: 0..1 over the icon, <0/>1 in the outline + // margin. This keeps the drain math below unchanged and samples the + // transparent padding (never a neighbour) when the quad is expanded. + vLocalUV = (aPos * quadSize - marginWorld) / iconWorldSize; + // UV from atlas grid (padded to avoid mipmap bleed) vec4 uv = cellUV(atlasIdx); - vUV = vec2(mix(uv.x, uv.z, aPos.x), mix(uv.y, uv.w, aPos.y)); - vLocalUV = aPos; + vUV = vec2(mix(uv.x, uv.z, vLocalUV.x), mix(uv.y, uv.w, vLocalUV.y)); // Alliance drain: slot 3 = alliance icon float allianceFrac = pd7.z;