I have been trying to do faction borders similar to how it is done in Paradox games, following this article:
I actually managed to get it quite nicely. I can have several different map modes that change how each province is displayed, and also if the borders are displayed between factions only, or if each province has its own borders inside the same faction.
Faction map mode, each faction has a border color and inner color
Province map mode that can display a stat about each province
Smooth borders when zoomed in
My issue is that if I try to create the border detection texture and the jump-flood textures on sizes lower than 8192, I get these artifacts in the generated Distance Field Texture:
Which in turn makes the final borders look broken
And If I use 8192 textures, it looks perfect BUT there is a significant spike in performance every time I update the map (which I must do every time a province changes hands or the user changes the map mode).
For posterity I’ll detail my whole process since on both reddit and this forum I’ve seen a lot of people questioning how to do grand strategy faction borders.
1) The map is generated by first using a territory lookup texture, which can be hand-drawn OR procedurally generated by taking the vector2 position of each province, and drawing a sphere of influence around it in the texture map (which is useful for procedurally generated maps, like Stellaris).
Hand-drawn texture lookup map
Procedurally generated texture lookup map
Each color on those maps corresponds to an ID. From that ID we can get the faction inner and border color as that information is passed to the shaders with a ComputeBuffer.
2) Then I pass the territory lookupmap and the ComputeBuffer with the faction data in this Border Detection Seed shader:
Shader "Custom/BorderSeed"
{
Properties
{
_MainTex ("_MainTex", 2D) = "white" {}
}
SubShader {
Tags { "RenderType"="Opaque" }
Pass {
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
sampler2D _MainTex;
float4 _MainTex_TexelSize;
struct v2f { float4 vertex:SV_POSITION; float2 uv:TEXCOORD0; };
v2f vert(appdata_full v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv =v.texcoord;
return o;
}
struct NodeStruct
{
int Id;
float4 borderColor;
float4 innerColor;
};
int _NodeCount;
StructuredBuffer<NodeStruct> _NodeBuffer;
uint GetNodeIndex(float4 color)
{
uint4 bytes = (uint4)((color * 255.0) + 0.5);
return (bytes.r | bytes.g << 8u);
}
bool IsClear(float4 color)
{
return all(color == float4(0, 0, 0, 0));
}
bool Equals(float4 color, float4 otherColor)
{
return (color.r == otherColor.r) && (color.g == otherColor.g) && (color.b == otherColor.b);
}
bool CheckBorder(float2 uvOffset, float4 color, int centerId)
{
float4 otherColor = tex2D(_MainTex, uvOffset);
if (IsClear(otherColor))
return true;
if (!Equals(otherColor, color))
{
int otherId = GetNodeIndex(otherColor);
if (otherId >= 0 && _NodeCount > otherId)
{
NodeStruct otherNode = _NodeBuffer[otherId];
if (centerId != otherNode.Id)
return true;
}
}
return false;
}
float4 frag(v2f i) : SV_Target
{
float2 uv = i.uv;
float4 center = tex2D(_MainTex, uv);
if (IsClear(center))
return float4(0, 0, 0, 0);
int id = GetNodeIndex(center);
int centerId = 0;
NodeStruct ownerNode;
if (_NodeCount > id)
{
ownerNode = _NodeBuffer[id];
centerId = ownerNode.Id;
}
bool isBorder = false;
float2 t = _MainTex_TexelSize.xy;
[unroll]
for (int x = -1; x <= 1; x++)
for (int y = -1; y <= 1; y++) {
if (x == 0 && y == 0)
continue;
if (CheckBorder(uv + float2(x, y) * t, center, centerId))
isBorder = true;
}
return isBorder ? float4(i.uv, 0, 1) : float4(0,0,0,0);
}
ENDCG
}
}
}
Which generates thin one-pixel lines like this:
3) Then I ping pong that border texture on my jump flood shader. That ping pong I am doing on C# and using Graphics.blit. Not sure if there is another and more optmized way of doing it (the C# code is at the end of the post).
Shader "Custom/JumpFlood"
{
Properties
{
_PrevTex ("_Texture", 2D) = "white" {}
_Step("Step", float) = 1
}
SubShader {
Tags { "RenderType"="Opaque" }
Pass {
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
sampler2D _PrevTex;
float _Step;
float4 _PrevTex_TexelSize;
struct v2f { float4 vertex:SV_POSITION; float2 uv:TEXCOORD0; };
v2f vert(appdata_full v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
return o;
}
float4 frag(v2f i) : SV_Target
{
float2 myPos = i.uv;
float4 center = tex2D(_PrevTex, i.uv);
float2 bestSeed = center.rg;
float bestDist = (center.a > 0) ? distance(myPos, bestSeed) : 999999;
float2 t = _PrevTex_TexelSize.xy * _Step;
for (int x =- 1; x <= 1; x++)
for (int y =- 1; y <= 1; y++)
{
if(x == 0 && y == 0)
continue;
float4 n = tex2D(_PrevTex, i.uv + float2(x,y)*t);
if(n.a > 0)
{
float d = distance(myPos, n.rg);
if(d < bestDist)
{
bestDist = d;
bestSeed = n.rg;
}
}
}
return float4(bestSeed, 0, bestDist < 999999 ? 1 : 0);
}
ENDCG
}
}
}
4) After all that, I pass the resulting render texture from the jump-flood steps to the Distance Field generator shader
Shader "Custom/DistanceCompute"
{
Properties
{
_SeedTex ("_SeedTex", 2D) = "white" {}
}
SubShader {
Pass {
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
sampler2D _SeedTex;
float4 _SeedTex_TexelSize;
struct v2f{float4 vertex:SV_POSITION; float2 uv:TEXCOORD0;};
v2f vert(appdata_full v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
return o;
}
float4 frag(v2f i):SV_Target
{
float2 seed = tex2D(_SeedTex,i.uv).rg;
float a = tex2D(_SeedTex,i.uv).a;
float d = a > 0 ? distance(i.uv, seed) : 0;
return float4(d,d,d,1);
}
ENDCG
}
}
}
Which generates this distance field texture
5) And finally, I simply set that distance field texture into my final territory shader (which also had the faction and province data ComputeBuffer passed into it)
Shader "Custom/TerritoryShader"
{
Properties
{
_MainTex ("_MainTex", 2D) = "white" {}
_DistanceField ("_DistanceField", 2D) = "white" {}
_BorderSize("_BorderSize", float)= 0.011
_FullBorderSize("_FullBorderSize", float)= 0.001
_BorderGap("_BorderGap", float)= 0.001
_InnerSize("_InnerSize", float)= 0.24
_BorderAlpha("_BorderAlpha", float) = 0.5
_FillAlpha("_FillAlpha", float) = 0.5
}
SubShader
{
Tags { "RenderType"="Transparent" "Queue"="Transparent"}
LOD 100
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
sampler2D _MainTex;
sampler2D _DistanceField;
float4 _MainTex_TexelSize;
struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; };
struct v2f { float4 vertex : SV_POSITION; float2 uv : TEXCOORD0; };
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}
struct NodeStruct
{
int Id;
float4 borderColor;
float4 innerColor;
};
float _BorderSize;
float _BorderGap;
float _InnerSize;
float _FullBorderSize;
float _BorderAlpha;
float _FillAlpha;
int _NodeCount;
StructuredBuffer<NodeStruct> _NodeBuffer;
uint GetNodeIndex(float4 color)
{
uint4 bytes = (uint4)((color * 255.0) + 0.5);
return (bytes.r | bytes.g << 8u);
}
bool IsClear(float4 color)
{
return all(color == float4(0, 0, 0, 0));
}
fixed4 frag(v2f i) : SV_Target
{
float2 uv = i.uv;
float dist = tex2D(_DistanceField, i.uv).r;
float4 center = tex2D(_MainTex, uv);
if (IsClear(center))
return float4(0, 0, 0, 0);
int id = GetNodeIndex(center);
int centerId = 0;
NodeStruct ownerNode;
if (_NodeCount > id)
{
ownerNode = _NodeBuffer[id];
centerId = ownerNode.Id;
}
bool isBorder = dist <= _BorderSize;
if (!isBorder)
{
float distanceToInner = dist - _BorderSize;
float aa = fwidth(distanceToInner) / max(_InnerSize, 1e-6);
float t = (_InnerSize - distanceToInner) / max(_InnerSize, 1e-6);
t = saturate(t);
float4 color = ownerNode.innerColor;
color.a = _FillAlpha;
return lerp(float4(0,0,0,0), color, t);
}
else
{
float aa = fwidth(dist);
float4 borderColor = ownerNode.borderColor; borderColor.a = _BorderAlpha;
float4 innerColor = ownerNode.innerColor; innerColor.a = _FillAlpha;
if (_InnerSize <= 0)
innerColor = float4(0,0,0,0);
float gapT = saturate(dist / max(_BorderGap, 1e-6));
gapT = smoothstep(0.0 - aa, 1.0 + aa, gapT);
float d = dist - _BorderGap;
d = max(d, 0.0);
float norm = saturate(d / max(_BorderSize, 1e-6));
float fullRegion = saturate(_FullBorderSize / max(_BorderSize, 1e-6));
float fadeRegion = max(1.0 - fullRegion, 1e-6);
float tBorder = saturate((norm - fullRegion) / fadeRegion);
tBorder = smoothstep(0.0 + aa, 1.0 - aa, tBorder);
tBorder = pow(tBorder, 0.3);
float4 borderToInner = lerp(borderColor, innerColor, tBorder);
if (dist > _BorderGap)
gapT = 1.0;
return lerp(float4(0,0,0,0), borderToInner, gapT);
}
}
ENDCG
}
}
}
My C# code is simply this:
private void RefreshTerritoryColors() { _dataList.Clear(); ClearBuffers(); bool useFactionId = _usesFactionId; int nodeId = 0; foreach (NodeModel node in _nodeModels) { int factionId = node.FactionId; (Color32, Color32) nodeColors = _getNodeColor(node); NodeStruct nodeStruct = new () { Id = useFactionId ? factionId : nodeId, // Decides if we should draw only the faction borders, or each province borders borderColor = nodeColors.Item1, innerColor = nodeColors.Item2, }; _dataList.Add(nodeStruct); nodeId++; } ComputeBuffer buffer = new ComputeBuffer(_dataList.Count, sizeof(float) * 8 + sizeof(int)); buffer.SetData(_dataList); _material.SetInt("_NodeCount", _dataList.Count); _material.SetBuffer("_NodeBuffer", buffer); _material.SetTexture("_MainTex", _usedTerritoryMap); _usedBuffers.Add(buffer); _nodeDistanceFieldGenerator.UpdateDistanceField(_material, _usedTerritoryMap, buffer, _dataList.Count); }
public void UpdateDistanceField(Material material, Texture2D territoryMap, ComputeBuffer territoryLookupBuffer, int bufferCount) { Clear(); _createdRenderTextures = true; int size = _seedAndJumpFloodSize; _rtA = CreateRT(size); _rtB = CreateRT(size); _distanceField = CreateRT(_distanceFieldTextureSize, true); // Border detection, generating the border lines by checking for different ids of the decoded colors of the nearby pixels _seedMaterial.SetTexture("_MainTex", territoryMap); _seedMaterial.SetInt("_NodeCount", bufferCount); _seedMaterial.SetBuffer("_NodeBuffer", territoryLookupBuffer); Graphics.Blit(territoryMap, _rtA, _seedMaterial); // Jump flood algorithm int step = size / _maxStepDivisor; while (step > 0) { _jumpFloodMaterial.SetFloat("_Step", step); _jumpFloodMaterial.SetTexture("_PrevTex", _rtA); Graphics.Blit(_rtA, _rtB, _jumpFloodMaterial); (_rtB, _rtA) = (_rtA, _rtB); step /= 2; } // Distance Field generator _distanceMaterial.SetTexture("_SeedTex", _rtA); Graphics.Blit(_rtA, _distanceField, _distanceMaterial); // Sets the distance field into the territory shader material.SetTexture("_DistanceField", _distanceField); } private RenderTexture CreateRT(int size, bool bilinearFiltering = false) { var rt = new RenderTexture(size, size, 0, RenderTextureFormat.RGFloat); rt.filterMode = bilinearFiltering ? FilterMode.Bilinear : FilterMode.Point; rt.useMipMap = false; rt.enableRandomWrite = false; rt.wrapMode = TextureWrapMode.Clamp; rt.Create(); return rt; }
For my question, step 5 (generating the territory borders) is irrelevant. I want to improve the generated distance field texture which is then used on the territory.
Not only I have the issue of those gaps in the line on near-straight (but not really straight) borders, but also on all border edges it isn’t smooth. As you can see below on the left (and also on every border in the distance field texture). Ideally it would be more smooth, like on the right
Any suggestions on how to improve or optimize this would be appreciated.









