Skip same-faction borders during faction highlighting (#6200)

When hovering a faction in the Factions table or viewing a faction
notification, borders between provinces of the same faction are now
suppressed so the faction's territory appears as one contiguous
highlighted region.

Adds a cross-province ID map (computed once at startup alongside the
distance map) that stores which province is on the other side of each
pixel's nearest border, plus a 256x1 highlight group lookup texture
that the shader checks to skip highlight borders between same-group
provinces.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 08:09:28 -08:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 782dc2135a
commit 13777a1e83
6 changed files with 164 additions and 33 deletions
@@ -233,6 +233,8 @@ namespace eagle {
SetDefaultProvinceColor(commandProvince);
}
provinceBorderManager.SetHighlightGroup(OverrideTargetedProvinces);
foreach (var pid in OverrideTargetedProvinces) {
FlashProvince(pid, _targetColor, TargetedBorderCycle);
HighlightProvinceBorder(
@@ -243,6 +245,7 @@ namespace eagle {
TargetedBorderCycle);
}
} else {
provinceBorderManager.ClearHighlightGroup();
if (SelectedProvinceId is ProvinceId sPid) {
FlashProvince(sPid, _selectionColor, SelectedBorderCycle);
HighlightProvinceBorder(
@@ -6,19 +6,24 @@ namespace eagle {
/// Each pixel stores the Euclidean distance (Chamfer approximation, in texels) to
/// the nearest province boundary, clamped to 255. The shader uses this to render
/// variable-width borders with a single texture sample per fragment.
///
/// Also generates a cross-province ID map: for each pixel, the province ID on the
/// other side of the nearest border. The shader uses this to suppress highlight
/// borders between provinces in the same highlight group.
/// </summary>
public class ProvinceBorderDistanceGenerator : MonoBehaviour {
public ProvinceIDLoader provinceIDLoader;
public Material provinceMapMaterial;
private Texture2D _distanceTexture;
private Texture2D _crossProvinceTexture;
void Start() {
var mapBytes = provinceIDLoader.DecompressedBytes;
int w = provinceIDLoader.mapWidth;
int h = provinceIDLoader.mapHeight;
var dist = ComputeDistanceMap(mapBytes, w, h);
var (dist, crossIds) = ComputeDistanceMap(mapBytes, w, h);
_distanceTexture = new Texture2D(w, h, TextureFormat.R8, false);
_distanceTexture.filterMode = FilterMode.Bilinear;
@@ -26,13 +31,21 @@ namespace eagle {
_distanceTexture.LoadRawTextureData(dist);
_distanceTexture.Apply();
_crossProvinceTexture = new Texture2D(w, h, TextureFormat.R8, false);
_crossProvinceTexture.filterMode = FilterMode.Point;
_crossProvinceTexture.wrapMode = TextureWrapMode.Clamp;
_crossProvinceTexture.LoadRawTextureData(crossIds);
_crossProvinceTexture.Apply();
provinceMapMaterial.SetTexture("_BorderDistanceMap", _distanceTexture);
provinceMapMaterial.SetTexture("_CrossProvinceIDMap", _crossProvinceTexture);
}
private static byte[] ComputeDistanceMap(byte[] ids, int w, int h) {
private static (byte[] dist, byte[] crossIds) ComputeDistanceMap(byte[] ids, int w, int h) {
const float DIAG = 1.414f;
int size = w * h;
var dist = new float[size];
var crossIds = new byte[size];
// Init: border pixels = 0, interior = large value
for (int y = 0; y < h; y++) {
@@ -43,24 +56,29 @@ namespace eagle {
// Ocean/invalid pixels are always border
if (id == 0 || id == 0xFF) {
dist[idx] = 0f;
crossIds[idx] = id;
continue;
}
// Check 8 neighbors for different province ID
bool isBorder = false;
byte crossId = id;
for (int dy = -1; dy <= 1 && !isBorder; dy++) {
for (int dx = -1; dx <= 1 && !isBorder; dx++) {
if (dx == 0 && dy == 0) continue;
int nx = x + dx, ny = y + dy;
if (nx < 0 || nx >= w || ny < 0 || ny >= h) {
isBorder = true;
crossId = 0;
} else if (ids[ny * w + nx] != id) {
isBorder = true;
crossId = ids[ny * w + nx];
}
}
}
dist[idx] = isBorder ? 0f : 255f;
crossIds[idx] = crossId;
}
}
@@ -71,11 +89,27 @@ namespace eagle {
if (dist[idx] == 0f) continue;
float d = dist[idx];
if (x > 0) d = Mathf.Min(d, dist[idx - 1] + 1f);
if (y > 0) d = Mathf.Min(d, dist[(y - 1) * w + x] + 1f);
if (x > 0 && y > 0) d = Mathf.Min(d, dist[(y - 1) * w + x - 1] + DIAG);
if (x < w - 1 && y > 0) d = Mathf.Min(d, dist[(y - 1) * w + x + 1] + DIAG);
int bestIdx = idx;
if (x > 0 && dist[idx - 1] + 1f < d) {
d = dist[idx - 1] + 1f;
bestIdx = idx - 1;
}
if (y > 0 && dist[(y - 1) * w + x] + 1f < d) {
d = dist[(y - 1) * w + x] + 1f;
bestIdx = (y - 1) * w + x;
}
if (x > 0 && y > 0 && dist[(y - 1) * w + x - 1] + DIAG < d) {
d = dist[(y - 1) * w + x - 1] + DIAG;
bestIdx = (y - 1) * w + x - 1;
}
if (x < w - 1 && y > 0 && dist[(y - 1) * w + x + 1] + DIAG < d) {
d = dist[(y - 1) * w + x + 1] + DIAG;
bestIdx = (y - 1) * w + x + 1;
}
dist[idx] = d;
if (bestIdx != idx) { crossIds[idx] = crossIds[bestIdx]; }
}
}
@@ -86,24 +120,41 @@ namespace eagle {
if (dist[idx] == 0f) continue;
float d = dist[idx];
if (x < w - 1) d = Mathf.Min(d, dist[idx + 1] + 1f);
if (y < h - 1) d = Mathf.Min(d, dist[(y + 1) * w + x] + 1f);
if (x < w - 1 && y < h - 1) d = Mathf.Min(d, dist[(y + 1) * w + x + 1] + DIAG);
if (x > 0 && y < h - 1) d = Mathf.Min(d, dist[(y + 1) * w + x - 1] + DIAG);
int bestIdx = idx;
if (x < w - 1 && dist[idx + 1] + 1f < d) {
d = dist[idx + 1] + 1f;
bestIdx = idx + 1;
}
if (y < h - 1 && dist[(y + 1) * w + x] + 1f < d) {
d = dist[(y + 1) * w + x] + 1f;
bestIdx = (y + 1) * w + x;
}
if (x < w - 1 && y < h - 1 && dist[(y + 1) * w + x + 1] + DIAG < d) {
d = dist[(y + 1) * w + x + 1] + DIAG;
bestIdx = (y + 1) * w + x + 1;
}
if (x > 0 && y < h - 1 && dist[(y + 1) * w + x - 1] + DIAG < d) {
d = dist[(y + 1) * w + x - 1] + DIAG;
bestIdx = (y + 1) * w + x - 1;
}
dist[idx] = d;
if (bestIdx != idx) { crossIds[idx] = crossIds[bestIdx]; }
}
}
// Pack into byte array
var result = new byte[size];
// Pack into byte arrays
var distResult = new byte[size];
for (int i = 0; i < size; i++) {
result[i] = (byte)Mathf.Min(Mathf.RoundToInt(dist[i]), 255);
distResult[i] = (byte)Mathf.Min(Mathf.RoundToInt(dist[i]), 255);
}
return result;
return (distResult, crossIds);
}
void OnDestroy() {
if (_distanceTexture != null) { Destroy(_distanceTexture); }
if (_crossProvinceTexture != null) { Destroy(_crossProvinceTexture); }
}
}
}
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using UnityEngine;
namespace eagle {
@@ -5,6 +6,8 @@ namespace eagle {
/// Manages 256x1 lookup textures for per-province border highlighting.
/// Border color and width are set per province and uploaded each frame
/// when dirty, mirroring ProvinceColorManager's batched approach.
/// Also manages a highlight-group lookup so the shader can suppress
/// highlight borders between provinces in the same group (e.g. same faction).
/// </summary>
public class ProvinceBorderManager : MonoBehaviour {
[Header("Material")]
@@ -16,9 +19,12 @@ namespace eagle {
private Texture2D _borderColorLookup;
private Texture2D _borderWidthLookup;
private Texture2D _highlightGroupLookup;
private Color[] _borderColors;
private Color[] _borderWidths;
private Color[] _highlightGroups;
private bool _dirty;
private bool _groupDirty;
void Awake() {
_borderColorLookup = new Texture2D(256, 1, TextureFormat.RGBA32, false);
@@ -29,20 +35,29 @@ namespace eagle {
_borderWidthLookup.filterMode = FilterMode.Point;
_borderWidthLookup.wrapMode = TextureWrapMode.Clamp;
_highlightGroupLookup = new Texture2D(256, 1, TextureFormat.RGBA32, false);
_highlightGroupLookup.filterMode = FilterMode.Point;
_highlightGroupLookup.wrapMode = TextureWrapMode.Clamp;
_borderColors = new Color[256];
_borderWidths = new Color[256];
_highlightGroups = new Color[256];
for (int i = 0; i < 256; i++) {
_borderColors[i] = Color.clear;
_borderWidths[i] = Color.clear;
_highlightGroups[i] = Color.clear;
}
_borderColorLookup.SetPixels(_borderColors);
_borderColorLookup.Apply();
_borderWidthLookup.SetPixels(_borderWidths);
_borderWidthLookup.Apply();
_highlightGroupLookup.SetPixels(_highlightGroups);
_highlightGroupLookup.Apply();
provinceMapMaterial.SetTexture("_BorderColorLookup", _borderColorLookup);
provinceMapMaterial.SetTexture("_BorderWidthLookup", _borderWidthLookup);
provinceMapMaterial.SetTexture("_HighlightGroupLookup", _highlightGroupLookup);
provinceMapMaterial.SetFloat("_DefaultBorderWidth", defaultBorderWidth);
provinceMapMaterial.SetColor("_DefaultBorderColor", defaultBorderColor);
}
@@ -61,6 +76,20 @@ namespace eagle {
_dirty = true;
}
public void SetHighlightGroup(IList<int> provinceIds) {
for (int i = 0; i < 256; i++) { _highlightGroups[i] = Color.clear; }
var groupColor = new Color(1f / 255f, 0, 0, 0);
foreach (var id in provinceIds) {
if (id >= 0 && id <= 255) { _highlightGroups[id] = groupColor; }
}
_groupDirty = true;
}
public void ClearHighlightGroup() {
for (int i = 0; i < 256; i++) { _highlightGroups[i] = Color.clear; }
_groupDirty = true;
}
void LateUpdate() {
if (_dirty) {
_borderColorLookup.SetPixels(_borderColors);
@@ -69,11 +98,17 @@ namespace eagle {
_borderWidthLookup.Apply();
_dirty = false;
}
if (_groupDirty) {
_highlightGroupLookup.SetPixels(_highlightGroups);
_highlightGroupLookup.Apply();
_groupDirty = false;
}
}
void OnDestroy() {
if (_borderColorLookup != null) Destroy(_borderColorLookup);
if (_borderWidthLookup != null) Destroy(_borderWidthLookup);
if (_highlightGroupLookup != null) Destroy(_highlightGroupLookup);
}
}
}
@@ -19,6 +19,10 @@ Shader "Eagle/ProvinceMap"
_DefaultBorderWidth ("Default Border Width", Float) = 0.35
_DefaultBorderColor ("Default Border Color", Color) = (0.15, 0.1, 0.05, 0.8)
// Same-faction border suppression
_CrossProvinceIDMap ("Cross Province ID Map", 2D) = "black" {}
_HighlightGroupLookup ("Highlight Group Lookup (256x1)", 2D) = "black" {}
// UI clipping support
_StencilComp ("Stencil Comparison", Float) = 8
_Stencil ("Stencil ID", Float) = 0
@@ -90,6 +94,8 @@ Shader "Eagle/ProvinceMap"
sampler2D _BorderDistanceMap;
sampler2D _BorderColorLookup;
sampler2D _BorderWidthLookup;
sampler2D _CrossProvinceIDMap;
sampler2D _HighlightGroupLookup;
float4 _ProvinceIDMap_ST;
float4 _OceanTex_ST;
float4 _BorderDistanceMap_TexelSize; // Unity auto-provides (1/w, 1/h, w, h)
@@ -161,11 +167,20 @@ Shader "Eagle/ProvinceMap"
float effectiveHighlightWidth = highlightWidthPx * texelsPerPixel;
fixed4 highlightColor = tex2D(_BorderColorLookup, float2(provinceId, 0.5));
// Check if the province across the nearest border is in the
// same highlight group (e.g. same faction). When both sides
// belong to the same group the highlight border is suppressed
// so the faction's territory looks like one contiguous region.
float crossProvId = tex2D(_CrossProvinceIDMap, i.uv).r;
float myGroup = tex2D(_HighlightGroupLookup, float2(provinceId, 0.5)).r;
float crossGroup = tex2D(_HighlightGroupLookup, float2(crossProvId, 0.5)).r;
bool sameGroup = myGroup > 0.001 && abs(myGroup - crossGroup) < 0.001;
// Scale anti-aliasing transitions
float defaultTransition = 0.5 * texelsPerPixel;
float highlightTransition = 1.5 * texelsPerPixel;
if (effectiveHighlightWidth > 0.5 && dist <= effectiveHighlightWidth)
if (effectiveHighlightWidth > 0.5 && dist <= effectiveHighlightWidth && !sameGroup)
{
float edge = 1.0 - saturate((dist - effectiveHighlightWidth + highlightTransition) / highlightTransition);
color = lerp(fillColor, highlightColor, highlightColor.a * edge);
@@ -6,28 +6,28 @@ EditorUserSettings:
serializedVersion: 4
m_ConfigSettings:
RecentlyUsedSceneGuid-0:
value: 5b02025f520c5a5f0b0d552640770c4946151a2e7d7a75697c7e4f66e1b2306c
flags: 0
RecentlyUsedSceneGuid-1:
value: 540206545d510c590f595d2143770f45444f1b73757e72357c791836e7e5373a
flags: 0
RecentlyUsedSceneGuid-2:
RecentlyUsedSceneGuid-1:
value: 06525702540c0f595c57547545770c48104e487d7d787062787e4864b7b7606b
flags: 0
RecentlyUsedSceneGuid-3:
RecentlyUsedSceneGuid-2:
value: 5a000356000d085a555c0d7343770b4241151e2b7e7070647f2d4e35bae46339
flags: 0
RecentlyUsedSceneGuid-4:
RecentlyUsedSceneGuid-3:
value: 0507035e5c0558095e5a5974477b064047454b7e2d2a2534282f4d62b0b3606d
flags: 0
RecentlyUsedSceneGuid-5:
RecentlyUsedSceneGuid-4:
value: 0703555650055e0b58565d7a41770643441641797b7b7263757a4931b1e46c6a
flags: 0
RecentlyUsedSceneGuid-5:
value: 015207020753505e590c597411770a4215164d282a7e7633757f496be4b0316c
flags: 0
RecentlyUsedSceneGuid-6:
value: 51070104510d505f5c565e2314750e44134e4c7a2a2a71357c714d62b0e2643c
value: 5b02025f520c5a5f0b0d552640770c4946151a2e7d7a75697c7e4f66e1b2306c
flags: 0
RecentlyUsedSceneGuid-7:
value: 015207020753505e590c597411770a4215164d282a7e7633757f496be4b0316c
value: 51070104510d505f5c565e2314750e44134e4c7a2a2a71357c714d62b0e2643c
flags: 0
RecentlyUsedScenePath-0:
value: 224247031146467c0c0309321c22465e0319113e35