Add debug controls for border visibility and seed sampling in TerritoryLayer and TerritoryWebGLRenderer

- Introduced UI elements in TerritoryLayer for toggling the visibility of static and all borders, as well as selecting seed sampling modes (none, 2x2, 3x3).
- Updated TerritoryWebGLRenderer to handle new debug options for border rendering and seed sampling, enhancing visual debugging capabilities.
- Enhanced shader logic to incorporate new debug settings, allowing for improved visualization of territory transitions and border behaviors.
- Improved logging to include new debug options for better tracking of rendering performance and state management.
This commit is contained in:
scamiv
2026-01-13 20:37:30 +01:00
parent d20a7fa060
commit e0d0c001d8
2 changed files with 528 additions and 129 deletions
@@ -91,6 +91,10 @@ export class TerritoryLayer implements Layer {
private smoothingDebugUi: HTMLDivElement | null = null;
private contestedPatternMode: "blueNoise" | "checkerboard" | "bayer4x4" =
"blueNoise";
private debugDisableStaticBorders = false;
private debugDisableAllBorders = false;
private seedSamplingMode: "none" | "2x2" | "3x3" = "2x2";
private debugStripeFixedColors = false;
constructor(
private game: GameView,
@@ -640,6 +644,112 @@ export class TerritoryLayer implements Layer {
contestedModeRow.appendChild(contestedModeSelect);
root.appendChild(contestedModeRow);
// Debug: hide all borders
const allBordersRow = document.createElement("label");
allBordersRow.style.display = "flex";
allBordersRow.style.alignItems = "center";
allBordersRow.style.gap = "6px";
allBordersRow.style.marginTop = "6px";
const allBordersCheckbox = document.createElement("input");
allBordersCheckbox.type = "checkbox";
allBordersCheckbox.checked = this.debugDisableAllBorders;
allBordersCheckbox.addEventListener("change", () => {
const disabled = allBordersCheckbox.checked;
this.debugDisableAllBorders = disabled;
this.territoryRenderer?.setDebugDisableAllBorders(disabled);
this.territoryRenderer?.markAllDirty();
});
const allBordersText = document.createElement("span");
allBordersText.textContent = "hide all borders";
allBordersRow.appendChild(allBordersCheckbox);
allBordersRow.appendChild(allBordersText);
root.appendChild(allBordersRow);
// Debug: hide non-smoothed (static) borders
const staticBordersRow = document.createElement("label");
staticBordersRow.style.display = "flex";
staticBordersRow.style.alignItems = "center";
staticBordersRow.style.gap = "6px";
staticBordersRow.style.marginTop = "6px";
const staticBordersCheckbox = document.createElement("input");
staticBordersCheckbox.type = "checkbox";
staticBordersCheckbox.checked = this.debugDisableStaticBorders;
staticBordersCheckbox.addEventListener("change", () => {
const disabled = staticBordersCheckbox.checked;
this.debugDisableStaticBorders = disabled;
this.territoryRenderer?.setDebugDisableStaticBorders(disabled);
this.territoryRenderer?.markAllDirty();
});
const staticBordersText = document.createElement("span");
staticBordersText.textContent = "hide static borders";
staticBordersRow.appendChild(staticBordersCheckbox);
staticBordersRow.appendChild(staticBordersText);
root.appendChild(staticBordersRow);
// Seed sampling mode dropdown (none / 2x2 / 3x3)
const seedSamplingRow = document.createElement("label");
seedSamplingRow.style.display = "flex";
seedSamplingRow.style.alignItems = "center";
seedSamplingRow.style.gap = "6px";
seedSamplingRow.style.marginTop = "6px";
const seedSamplingText = document.createElement("span");
seedSamplingText.textContent = "seed sampling";
const seedSamplingSelect = document.createElement("select");
seedSamplingSelect.style.background = "rgba(0,0,0,0.5)";
seedSamplingSelect.style.color = "#fff";
seedSamplingSelect.style.border = "1px solid rgba(255,255,255,0.2)";
seedSamplingSelect.style.borderRadius = "4px";
seedSamplingSelect.style.padding = "2px 4px";
const seedModes: Array<"none" | "2x2" | "3x3"> = ["none", "2x2", "3x3"];
for (const m of seedModes) {
const opt = document.createElement("option");
opt.value = m;
opt.textContent = m;
seedSamplingSelect.appendChild(opt);
}
seedSamplingSelect.value = this.seedSamplingMode;
seedSamplingSelect.addEventListener("change", () => {
const v = seedSamplingSelect.value as "none" | "2x2" | "3x3";
this.seedSamplingMode = v;
this.territoryRenderer?.setSeedSamplingMode(v);
this.territoryRenderer?.markAllDirty();
});
seedSamplingRow.appendChild(seedSamplingText);
seedSamplingRow.appendChild(seedSamplingSelect);
root.appendChild(seedSamplingRow);
// Debug: fixed stripe colors
const stripeColorsRow = document.createElement("label");
stripeColorsRow.style.display = "flex";
stripeColorsRow.style.alignItems = "center";
stripeColorsRow.style.gap = "6px";
stripeColorsRow.style.marginTop = "6px";
const stripeColorsCheckbox = document.createElement("input");
stripeColorsCheckbox.type = "checkbox";
stripeColorsCheckbox.checked = this.debugStripeFixedColors;
stripeColorsCheckbox.addEventListener("change", () => {
const enabled = stripeColorsCheckbox.checked;
this.debugStripeFixedColors = enabled;
this.territoryRenderer?.setDebugStripeFixedColors(enabled);
this.territoryRenderer?.markAllDirty();
});
const stripeColorsText = document.createElement("span");
stripeColorsText.textContent =
"fixed stripe colors (red=expand, blue=retreat, green=owner)";
stripeColorsRow.appendChild(stripeColorsCheckbox);
stripeColorsRow.appendChild(stripeColorsText);
root.appendChild(stripeColorsRow);
document.body.appendChild(root);
this.smoothingDebugUi = root;
}
@@ -717,6 +827,16 @@ export class TerritoryLayer implements Layer {
this.territoryRenderer = renderer;
this.territoryRenderer.setContestEnabled(this.contestEnabled);
this.territoryRenderer.setContestPatternMode(this.contestedPatternMode);
this.territoryRenderer.setDebugDisableStaticBorders(
this.debugDisableStaticBorders,
);
this.territoryRenderer.setDebugDisableAllBorders(
this.debugDisableAllBorders,
);
this.territoryRenderer.setSeedSamplingMode(this.seedSamplingMode);
this.territoryRenderer.setDebugStripeFixedColors(
this.debugStripeFixedColors,
);
this.territoryRenderer.setAlternativeView(this.alternativeView);
this.territoryRenderer.markAllDirty();
this.territoryRenderer.refreshPalette();
@@ -1444,6 +1564,8 @@ export class TerritoryLayer implements Layer {
`jfa: ${jfaStatus} dirty ${stats.jfaDirty ? "yes" : "no"}`,
`contests: ${this.contestEnabled ? "on" : "off"} comps ${this.contestComponents.size}`,
`contestPattern: ${this.contestedPatternMode}`,
`hideAllBorders: ${this.debugDisableAllBorders ? "yes" : "no"}`,
`hideStaticBorders: ${this.debugDisableStaticBorders ? "yes" : "no"}`,
`contestTiles: ${this.contestTileCount}`,
`contestTicks: ${this.contestDurationTicks}`,
`hovered: ${stats.hoveredPlayerId}`,
@@ -29,6 +29,10 @@ export class TerritoryWebGLRenderer {
private contestEnabled = false;
private contestPatternMode: 0 | 1 | 2 = 0; // 0=blueNoise(strength), 1=checkerboard(50/50), 2=bayer4x4(strength)
private debugDisableStaticBorders = false;
private debugDisableAllBorders = false;
private seedSamplingMode: 0 | 1 | 2 = 1; // 0=none(single texel), 1=2x2, 2=3x3
private debugStripeFixedColors = false; // Use fixed debug colors for moving stripe
private readonly gl: WebGL2RenderingContext | null;
private readonly program: WebGLProgram | null;
@@ -96,6 +100,10 @@ export class TerritoryWebGLRenderer {
patterns: WebGLUniformLocation | null;
contestEnabled: WebGLUniformLocation | null;
contestPatternMode: WebGLUniformLocation | null;
debugDisableStaticBorders: WebGLUniformLocation | null;
debugDisableAllBorders: WebGLUniformLocation | null;
seedSamplingMode: WebGLUniformLocation | null;
debugStripeFixedColors: WebGLUniformLocation | null;
contestOwners: WebGLUniformLocation | null;
contestIds: WebGLUniformLocation | null;
contestTimes: WebGLUniformLocation | null;
@@ -261,6 +269,10 @@ export class TerritoryWebGLRenderer {
patterns: null,
contestEnabled: null,
contestPatternMode: null,
debugDisableStaticBorders: null,
debugDisableAllBorders: null,
seedSamplingMode: null,
debugStripeFixedColors: null,
contestOwners: null,
contestIds: null,
contestTimes: null,
@@ -355,6 +367,10 @@ export class TerritoryWebGLRenderer {
patterns: null,
contestEnabled: null,
contestPatternMode: null,
debugDisableStaticBorders: null,
debugDisableAllBorders: null,
seedSamplingMode: null,
debugStripeFixedColors: null,
contestOwners: null,
contestIds: null,
contestTimes: null,
@@ -453,6 +469,22 @@ export class TerritoryWebGLRenderer {
this.program,
"u_contestPatternMode",
),
debugDisableStaticBorders: gl.getUniformLocation(
this.program,
"u_debugDisableStaticBorders",
),
debugDisableAllBorders: gl.getUniformLocation(
this.program,
"u_debugDisableAllBorders",
),
seedSamplingMode: gl.getUniformLocation(
this.program,
"u_seedSamplingMode",
),
debugStripeFixedColors: gl.getUniformLocation(
this.program,
"u_debugStripeFixedColors",
),
contestOwners: gl.getUniformLocation(this.program, "u_contestOwners"),
contestIds: gl.getUniformLocation(this.program, "u_contestIds"),
contestTimes: gl.getUniformLocation(this.program, "u_contestTimes"),
@@ -1298,6 +1330,22 @@ export class TerritoryWebGLRenderer {
else this.contestPatternMode = 0;
}
setDebugDisableStaticBorders(disabled: boolean) {
this.debugDisableStaticBorders = disabled;
}
setDebugDisableAllBorders(disabled: boolean) {
this.debugDisableAllBorders = disabled;
}
setSeedSamplingMode(mode: "none" | "2x2" | "3x3") {
this.seedSamplingMode = mode === "none" ? 0 : mode === "2x2" ? 1 : 2;
}
setDebugStripeFixedColors(enabled: boolean) {
this.debugStripeFixedColors = enabled;
}
markTile(tile: TileRef) {
if (this.needsFullUpload) {
return;
@@ -1774,6 +1822,27 @@ export class TerritoryWebGLRenderer {
if (this.uniforms.contestPatternMode) {
gl.uniform1i(this.uniforms.contestPatternMode, this.contestPatternMode);
}
if (this.uniforms.debugDisableStaticBorders) {
gl.uniform1i(
this.uniforms.debugDisableStaticBorders,
this.debugDisableStaticBorders ? 1 : 0,
);
}
if (this.uniforms.debugDisableAllBorders) {
gl.uniform1i(
this.uniforms.debugDisableAllBorders,
this.debugDisableAllBorders ? 1 : 0,
);
}
if (this.uniforms.seedSamplingMode) {
gl.uniform1i(this.uniforms.seedSamplingMode, this.seedSamplingMode);
}
if (this.uniforms.debugStripeFixedColors) {
gl.uniform1i(
this.uniforms.debugStripeFixedColors,
this.debugStripeFixedColors ? 1 : 0,
);
}
if (this.uniforms.contestNow) {
gl.uniform1i(this.uniforms.contestNow, this.contestNow);
}
@@ -2479,18 +2548,26 @@ export class TerritoryWebGLRenderer {
uint owner = ownerAt(texCoord);
bool isBorder = false;
vec2 edgeDir = vec2(0.0);
uint nOwner = ownerAt(texCoord + ivec2(1, 0));
isBorder = isBorder || (nOwner != owner);
if (nOwner != owner) { isBorder = true; edgeDir += vec2(1.0, 0.0); }
nOwner = ownerAt(texCoord + ivec2(-1, 0));
isBorder = isBorder || (nOwner != owner);
if (nOwner != owner) { isBorder = true; edgeDir += vec2(-1.0, 0.0); }
nOwner = ownerAt(texCoord + ivec2(0, 1));
isBorder = isBorder || (nOwner != owner);
if (nOwner != owner) { isBorder = true; edgeDir += vec2(0.0, 1.0); }
nOwner = ownerAt(texCoord + ivec2(0, -1));
isBorder = isBorder || (nOwner != owner);
if (nOwner != owner) { isBorder = true; edgeDir += vec2(0.0, -1.0); }
// Seed in map-space at the *tile center* so we can later interpret the
// boundary as half a tile away (distance-to-edge = distance-to-center - 0.5).
outSeed = isBorder ? (vec2(texCoord) + vec2(0.5)) : vec2(-1.0, -1.0);
vec2 edgeOffset = vec2(
edgeDir.x == 0.0 ? 0.0 : (edgeDir.x > 0.0 ? 0.5 : -0.5),
edgeDir.y == 0.0 ? 0.0 : (edgeDir.y > 0.0 ? 0.5 : -0.5)
);
// Seed at the border edge (tile center +/- 0.5) so the front can move
// even when the border tile itself stays the same.
outSeed = isBorder
? (vec2(texCoord) + vec2(0.5) + edgeOffset)
: vec2(-1.0, -1.0);
}
`;
@@ -2731,6 +2808,10 @@ export class TerritoryWebGLRenderer {
uniform usampler2D u_patterns;
uniform bool u_contestEnabled;
uniform int u_contestPatternMode; // 0=blueNoise(strength), 1=checkerboard(50/50), 2=bayer4x4(strength)
uniform bool u_debugDisableStaticBorders;
uniform bool u_debugDisableAllBorders;
uniform int u_seedSamplingMode; // 0=none(single texel), 1=2x2, 2=3x3
uniform bool u_debugStripeFixedColors; // Use fixed debug colors for moving stripe
uniform usampler2D u_contestOwners;
uniform usampler2D u_contestIds;
uniform usampler2D u_contestTimes;
@@ -2768,13 +2849,17 @@ export class TerritoryWebGLRenderer {
out vec4 outColor;
uint ownerAtTex(ivec2 texCoord) {
uint stateAtTex(ivec2 texCoord) {
ivec2 clamped = clamp(
texCoord,
ivec2(0, 0),
ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)
);
return texelFetch(u_state, clamped, 0).r & 0xFFFu;
return texelFetch(u_state, clamped, 0).r;
}
uint ownerAtTex(ivec2 texCoord) {
return stateAtTex(texCoord) & 0xFFFu;
}
// Terrain bit layout: bit7=land, bit6=shoreline, bit5=ocean, bits0-4=magnitude
@@ -2881,13 +2966,17 @@ export class TerritoryWebGLRenderer {
}
}
uint prevOwnerAtTex(ivec2 texCoord) {
uint prevStateAtTex(ivec2 texCoord) {
ivec2 clamped = clamp(
texCoord,
ivec2(0, 0),
ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)
);
return texelFetch(u_prevOwner, clamped, 0).r & 0xFFFu;
return texelFetch(u_prevOwner, clamped, 0).r;
}
uint prevOwnerAtTex(ivec2 texCoord) {
return prevStateAtTex(texCoord) & 0xFFFu;
}
vec2 jfaSeedOldAtTex(ivec2 texCoord) {
@@ -2916,6 +3005,59 @@ export class TerritoryWebGLRenderer {
return texelFetch(u_jfaSeedsNew, clamped, 0).rg;
}
// Best-of-NxN seed sampling to reduce tile-boundary discontinuities.
// Returns the seed (from OLD JFA) that is closest to mapCoord.
vec2 bestSeedOld(vec2 mapCoord) {
ivec2 base = ivec2(floor(mapCoord));
float bestDist = 1e9;
vec2 bestSeed = vec2(-1.0);
int radius = u_seedSamplingMode == 2 ? 1 : 0; // 3x3 vs 2x2
int end = u_seedSamplingMode == 2 ? 2 : 2; // 3x3: -1..+1, 2x2: 0..+1
int start = u_seedSamplingMode == 2 ? -1 : 0;
for (int dy = start; dy < end; dy++) {
for (int dx = start; dx < end; dx++) {
ivec2 sampleTex = base + ivec2(dx, dy);
vec2 seed = jfaSeedOldAtTex(sampleTex);
if (seed.x >= 0.0) {
float d = distance(mapCoord, seed);
if (d < bestDist) {
bestDist = d;
bestSeed = seed;
}
}
}
}
return bestSeed;
}
// Best-of-NxN seed sampling for NEW JFA.
vec2 bestSeedNew(vec2 mapCoord) {
ivec2 base = ivec2(floor(mapCoord));
float bestDist = 1e9;
vec2 bestSeed = vec2(-1.0);
int radius = u_seedSamplingMode == 2 ? 1 : 0;
int end = u_seedSamplingMode == 2 ? 2 : 2;
int start = u_seedSamplingMode == 2 ? -1 : 0;
for (int dy = start; dy < end; dy++) {
for (int dx = start; dx < end; dx++) {
ivec2 sampleTex = base + ivec2(dx, dy);
vec2 seed = jfaSeedNewAtTex(sampleTex);
if (seed.x >= 0.0) {
float d = distance(mapCoord, seed);
if (d < bestDist) {
bestDist = d;
bestSeed = seed;
}
}
}
}
return bestSeed;
}
uvec2 contestOwnersAtTex(ivec2 texCoord) {
ivec2 clamped = clamp(
texCoord,
@@ -3053,7 +3195,22 @@ export class TerritoryWebGLRenderer {
return color * (isLightTile ? LIGHT_FACTOR : DARK_FACTOR);
}
void main() {
vec3 applyBorderTint(vec3 color, bool hasFriendly, bool hasEmbargo) {
const float BORDER_TINT_RATIO = 0.35;
const vec3 FRIENDLY_TINT_TARGET = vec3(0.0, 1.0, 0.0);
const vec3 EMBARGO_TINT_TARGET = vec3(1.0, 0.0, 0.0);
if (hasFriendly) {
color = color * (1.0 - BORDER_TINT_RATIO) +
FRIENDLY_TINT_TARGET * BORDER_TINT_RATIO;
}
if (hasEmbargo) {
color = color * (1.0 - BORDER_TINT_RATIO) +
EMBARGO_TINT_TARGET * BORDER_TINT_RATIO;
}
return color;
}
void main() {
// gl_FragCoord.xy is already at pixel center (0.5, 0.5 ...).
// Use the pixel center to avoid half-pixel snapping/offset artifacts,
// especially noticeable on the interpolated JFA border/front.
@@ -3076,21 +3233,38 @@ export class TerritoryWebGLRenderer {
// Original ivec2(mapCoord) is equivalent but less explicit.
ivec2 texCoord = ivec2(mapCoord);
uint state = texelFetch(u_state, texCoord, 0).r;
uint state = stateAtTex(texCoord);
uint owner = state & 0xFFFu;
bool hasFallout = (state & 0x2000u) != 0u;
bool isDefended = (state & 0x1000u) != 0u;
uint latestState = texelFetch(u_latestState, texCoord, 0).r;
uint latestOwner = latestState & 0xFFFu;
uint oldOwner = prevOwnerAtTex(texCoord);
uint oldState = prevStateAtTex(texCoord);
uint oldOwner = oldState & 0xFFFu;
bool oldHasFallout = (oldState & 0x2000u) != 0u;
bool oldIsDefended = (oldState & 0x1000u) != 0u;
// ChangeMask was written with Y-flipped coords, so flip when reading
ivec2 changeMaskCoord = ivec2(texCoord.x, int(u_mapResolution.y) - 1 - texCoord.y);
uint changeMask = texelFetch(u_changeMask, changeMaskCoord, 0).r;
// Expand the animation region by 1 tile (halo) so the *outer* border edge can move smoothly.
// If we only animate "changed" tiles, the leading edge stays pinned to tile coordinates because
// neighbor pixels are still rendered from the static FROM snapshot.
uint affectedMask = changeMask;
ivec2 cm;
cm = ivec2(clamp(texCoord.x + 1, 0, int(u_mapResolution.x) - 1), texCoord.y);
affectedMask |= texelFetch(u_changeMask, ivec2(cm.x, int(u_mapResolution.y) - 1 - cm.y), 0).r;
cm = ivec2(clamp(texCoord.x - 1, 0, int(u_mapResolution.x) - 1), texCoord.y);
affectedMask |= texelFetch(u_changeMask, ivec2(cm.x, int(u_mapResolution.y) - 1 - cm.y), 0).r;
cm = ivec2(texCoord.x, clamp(texCoord.y + 1, 0, int(u_mapResolution.y) - 1));
affectedMask |= texelFetch(u_changeMask, ivec2(cm.x, int(u_mapResolution.y) - 1 - cm.y), 0).r;
cm = ivec2(texCoord.x, clamp(texCoord.y - 1, 0, int(u_mapResolution.y) - 1));
affectedMask |= texelFetch(u_changeMask, ivec2(cm.x, int(u_mapResolution.y) - 1 - cm.y), 0).r;
bool smoothActive = u_smoothEnabled &&
u_smoothProgress < 1.0 &&
!u_alternativeView &&
u_jfaAvailable &&
changeMask != 0u;
affectedMask != 0u;
uint contestIdRaw = 0u;
const uint CONTEST_ID_MASK = 0x7FFFu;
@@ -3160,7 +3334,7 @@ export class TerritoryWebGLRenderer {
if (u_alternativeView) {
// Alt view: terrain + borders only, no territory fill
vec3 color = baseTerrainColor;
if (owner != 0u && isBorder) {
if (!u_debugDisableAllBorders && !u_debugDisableStaticBorders && owner != 0u && isBorder) {
// Only draw borders, not territory fill
uint relationAlt = relationCode(owner, uint(u_viewerId));
vec4 altColor = u_altNeutral;
@@ -3207,29 +3381,19 @@ export class TerritoryWebGLRenderer {
);
ownerBase = base.rgb;
ownerBorder = baseBorder;
bool isPrimary = patternIsPrimary(owner, texCoord);
vec3 patternColor = isPrimary ? base.rgb : baseBorder.rgb;
// Blend territory fill on top of terrain
fillColor = mix(baseTerrainColor, patternColor, u_alpha);
if (isBorder && !smoothActive) {
vec3 bColor = baseBorder.rgb;
const float BORDER_TINT_RATIO = 0.35;
const vec3 FRIENDLY_TINT_TARGET = vec3(0.0, 1.0, 0.0);
const vec3 EMBARGO_TINT_TARGET = vec3(1.0, 0.0, 0.0);
if (hasFriendlyRelation) {
bColor = bColor * (1.0 - BORDER_TINT_RATIO) +
FRIENDLY_TINT_TARGET * BORDER_TINT_RATIO;
}
if (hasEmbargoRelation) {
bColor = bColor * (1.0 - BORDER_TINT_RATIO) +
EMBARGO_TINT_TARGET * BORDER_TINT_RATIO;
}
vec3 bColor = applyBorderTint(
baseBorder.rgb,
hasFriendlyRelation,
hasEmbargoRelation
);
borderColor = applyDefended(bColor, isDefended, texCoord);
borderAlpha = baseBorder.a;
} else {
bool isPrimary = patternIsPrimary(owner, texCoord);
vec3 patternColor = isPrimary ? base.rgb : baseBorder.rgb;
// Blend territory fill on top of terrain
fillColor = mix(baseTerrainColor, patternColor, u_alpha);
}
}
@@ -3258,9 +3422,9 @@ export class TerritoryWebGLRenderer {
color = mix(baseTerrainColor, contestColor, u_alpha);
}
if (!smoothActive && isBorder && owner != 0u) {
// Blend border on top of terrain
color = mix(baseTerrainColor, borderColor, borderAlpha);
if (!u_debugDisableAllBorders && !u_debugDisableStaticBorders && !smoothActive && isBorder && owner != 0u) {
// Blend border on top of the current fill
color = mix(color, borderColor, borderAlpha);
}
if (smoothActive) {
@@ -3271,7 +3435,7 @@ export class TerritoryWebGLRenderer {
// Compute old color blended on terrain
vec3 oldColor = baseTerrainColor;
if (oldOwner == 0u) {
if (hasFallout) {
if (oldHasFallout) {
oldColor = mix(baseTerrainColor, u_fallout.rgb, u_alpha);
}
// Otherwise oldColor is already baseTerrainColor
@@ -3289,8 +3453,17 @@ export class TerritoryWebGLRenderer {
// JFA-based animation with tile-sized pixelated look
// Movement is pixel-smooth but edges remain hard/blocky like stable borders
vec2 seedOld = jfaSeedOldAtTex(texCoord);
vec2 seedNew = jfaSeedNewAtTex(texCoord);
// Use best-of-NxN seed sampling when enabled to reduce tile-boundary discontinuities.
// Use seeds picked at the TILE CENTER to avoid seed flipping inside a tile
// (which can cause direction/timing glitches). Distances still use mapCoord
// for smooth within-tile variation.
vec2 tileCenter = floor(mapCoord) + 0.5;
vec2 seedOld = u_seedSamplingMode == 0
? jfaSeedOldAtTex(texCoord)
: bestSeedOld(tileCenter);
vec2 seedNew = u_seedSamplingMode == 0
? jfaSeedNewAtTex(texCoord)
: bestSeedNew(tileCenter);
bool hasOldSeed = seedOld.x >= 0.0;
bool hasNewSeed = seedNew.x >= 0.0;
@@ -3309,8 +3482,8 @@ export class TerritoryWebGLRenderer {
float t = clamp(u_smoothProgress, 0.0, 1.0);
// --- Old layer (FROM snapshot), at texCoord ---
uint fromState = texelFetch(u_prevOwner, texCoord, 0).r;
uint fromOwner = fromState & 0xFFFu;
uint fromState = oldState;
uint fromOwner = oldOwner;
// Fill for FROM owner
vec3 fromColor = baseTerrainColor;
@@ -3324,7 +3497,7 @@ export class TerritoryWebGLRenderer {
bool fromPrimary = patternIsPrimary(fromOwner, texCoord);
vec3 fromPatternColor = fromPrimary ? fromBase.rgb : fromBorderBase.rgb;
fromColor = mix(baseTerrainColor, fromPatternColor, u_alpha);
} else if (hasFallout) {
} else if (oldHasFallout) {
// preserve fallout tint when unowned
fromColor = mix(baseTerrainColor, u_fallout.rgb, u_alpha);
}
@@ -3342,104 +3515,208 @@ export class TerritoryWebGLRenderer {
nFrom = texelFetch(u_prevOwner, texCoord + ivec2(0, -1), 0).r & 0xFFFu;
if (nFrom != fromOwner) { fromIsBorder = true; if (nFrom != 0u) fromOther = nFrom; }
if (fromIsBorder && fromOwner != 0u) {
if (!u_debugDisableAllBorders && !u_debugDisableStaticBorders && fromIsBorder && fromOwner != 0u) {
vec4 borderBase = texelFetch(u_palette, ivec2(int(fromOwner) * 2 + 1, 0), 0);
vec3 bColor = borderBase.rgb;
bool fromFriendly = false;
bool fromEmbargo = false;
if (fromOther != 0u) {
uint rel = relationCode(fromOwner, fromOther);
const float BORDER_TINT_RATIO = 0.35;
const vec3 FRIENDLY_TINT_TARGET = vec3(0.0, 1.0, 0.0);
const vec3 EMBARGO_TINT_TARGET = vec3(1.0, 0.0, 0.0);
if (isFriendly(rel)) {
bColor = bColor * (1.0 - BORDER_TINT_RATIO) + FRIENDLY_TINT_TARGET * BORDER_TINT_RATIO;
}
if (isEmbargo(rel)) {
bColor = bColor * (1.0 - BORDER_TINT_RATIO) + EMBARGO_TINT_TARGET * BORDER_TINT_RATIO;
}
fromFriendly = isFriendly(rel);
fromEmbargo = isEmbargo(rel);
}
bColor = applyDefended(bColor, isDefended, texCoord);
vec3 bColor = applyBorderTint(
borderBase.rgb,
fromFriendly,
fromEmbargo
);
bColor = applyDefended(bColor, oldIsDefended, texCoord);
fromColor = bColor;
}
// Start with FROM layer
color = fromColor;
// Only slide in NEW layer where the pair's change mask says "changed".
if (changeMask != 0u) {
// Displacement from old border to new border (old -> new).
vec2 disp = vec2(0.0);
if (hasOldSeed && hasNewSeed) {
disp = seedNew - seedOld;
} else if (hasOldSeed) {
// Fallback: move along gradient away from old seed
vec2 away = mapCoord - seedOld;
float d = length(away);
if (d > 0.1) {
disp = normalize(away) * min(d, 2.0);
}
}
// Draw a *constant-width* moving border stripe between the FROM and TO snapshots.
// Use a planar front (not radial) that moves coherently across tiles based on
// the displacement direction from old->new seeds.
if (affectedMask != 0u && hasOldSeed && hasNewSeed) {
vec2 disp = seedNew - seedOld;
float dispLen = length(disp);
if (dispLen > 1e-4) {
vec2 dir = disp / dispLen;
// New layer starts shifted "ahead" (in direction of expansion) and slides back into place.
// This makes the new border advance smoothly without sampling future snapshots.
vec2 sampleCoord = mapCoord + (1.0 - t) * disp;
ivec2 toTex = ivec2(sampleCoord);
toTex = clamp(
toTex,
ivec2(0),
ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)
);
// Project mapCoord onto the displacement direction, measured from seedOld.
// This gives us a global coordinate along the motion axis.
// At t=0, front should be near seedOld (s ≈ 0).
// At t=1, front should be near seedNew (s ≈ dispLen).
float s = dot(mapCoord - seedOld, dir);
uint toState = texelFetch(u_state, toTex, 0).r;
uint toOwner = toState & 0xFFFu;
vec3 toColor = baseTerrainColor;
if (toOwner != 0u) {
vec4 toBase = texelFetch(u_palette, ivec2(int(toOwner) * 2, 0), 0);
vec4 toBorderBase = texelFetch(
u_palette,
ivec2(int(toOwner) * 2 + 1, 0),
0
);
bool toPrimary = patternIsPrimary(toOwner, toTex);
vec3 toPatternColor = toPrimary ? toBase.rgb : toBorderBase.rgb;
toColor = mix(baseTerrainColor, toPatternColor, u_alpha);
} else if (hasFallout) {
toColor = mix(baseTerrainColor, u_fallout.rgb, u_alpha);
}
bool toIsBorder = false;
uint toOther = 0u;
uint nTo;
nTo = texelFetch(u_state, toTex + ivec2(1, 0), 0).r & 0xFFFu;
if (nTo != toOwner) { toIsBorder = true; if (nTo != 0u) toOther = nTo; }
nTo = texelFetch(u_state, toTex + ivec2(-1, 0), 0).r & 0xFFFu;
if (nTo != toOwner) { toIsBorder = true; if (nTo != 0u) toOther = nTo; }
nTo = texelFetch(u_state, toTex + ivec2(0, 1), 0).r & 0xFFFu;
if (nTo != toOwner) { toIsBorder = true; if (nTo != 0u) toOther = nTo; }
nTo = texelFetch(u_state, toTex + ivec2(0, -1), 0).r & 0xFFFu;
if (nTo != toOwner) { toIsBorder = true; if (nTo != 0u) toOther = nTo; }
if (toIsBorder && toOwner != 0u) {
vec4 borderBase = texelFetch(u_palette, ivec2(int(toOwner) * 2 + 1, 0), 0);
vec3 bColor = borderBase.rgb;
if (toOther != 0u) {
uint rel = relationCode(toOwner, toOther);
const float BORDER_TINT_RATIO = 0.35;
const vec3 FRIENDLY_TINT_TARGET = vec3(0.0, 1.0, 0.0);
const vec3 EMBARGO_TINT_TARGET = vec3(1.0, 0.0, 0.0);
if (isFriendly(rel)) {
bColor = bColor * (1.0 - BORDER_TINT_RATIO) + FRIENDLY_TINT_TARGET * BORDER_TINT_RATIO;
// Front position moves from old border to new border along the motion axis.
// Seeds are placed at border edges, so no extra offset is needed.
float frontPos = t * dispLen;
// Signed distance from the moving front plane.
// Positive means the front has passed this point (new territory side).
float frontDist = frontPos - s;
// Compute the sliding position: sample owners at the position where the front currently is.
// This ensures owner checks happen at the sliding position, not static.
vec2 slideOffsetFront = (frontPos - s) * dir; // Offset from current position to front position
vec2 slideCoordFront = mapCoord + slideOffsetFront;
ivec2 slideTexFront = clamp(ivec2(slideCoordFront), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
// Sample owners at the sliding position
uint slideState = texelFetch(u_state, slideTexFront, 0).r;
uint slideOwner = slideState & 0xFFFu;
bool slideHasFallout = (slideState & 0x2000u) != 0u;
bool slideIsDefended = (slideState & 0x1000u) != 0u;
// Check if we're on a border at the sliding position (this is where the border currently is)
bool slideIsBorder = false;
bool slideHasFriendly = false;
bool slideHasEmbargo = false;
uint slideOther = 0u;
uint nSlide;
ivec2 nSlideTex;
nSlideTex = clamp(slideTexFront + ivec2(1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
nSlide = texelFetch(u_state, nSlideTex, 0).r & 0xFFFu;
if (nSlide != slideOwner) { slideIsBorder = true; if (nSlide != 0u) { slideOther = nSlide; uint rel = relationCode(slideOwner, nSlide); slideHasFriendly = slideHasFriendly || isFriendly(rel); slideHasEmbargo = slideHasEmbargo || isEmbargo(rel); } }
nSlideTex = clamp(slideTexFront + ivec2(-1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
nSlide = texelFetch(u_state, nSlideTex, 0).r & 0xFFFu;
if (nSlide != slideOwner) { slideIsBorder = true; if (nSlide != 0u) { slideOther = nSlide; uint rel = relationCode(slideOwner, nSlide); slideHasFriendly = slideHasFriendly || isFriendly(rel); slideHasEmbargo = slideHasEmbargo || isEmbargo(rel); } }
nSlideTex = clamp(slideTexFront + ivec2(0, 1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
nSlide = texelFetch(u_state, nSlideTex, 0).r & 0xFFFu;
if (nSlide != slideOwner) { slideIsBorder = true; if (nSlide != 0u) { slideOther = nSlide; uint rel = relationCode(slideOwner, nSlide); slideHasFriendly = slideHasFriendly || isFriendly(rel); slideHasEmbargo = slideHasEmbargo || isEmbargo(rel); } }
nSlideTex = clamp(slideTexFront + ivec2(0, -1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
nSlide = texelFetch(u_state, nSlideTex, 0).r & 0xFFFu;
if (nSlide != slideOwner) { slideIsBorder = true; if (nSlide != 0u) { slideOther = nSlide; uint rel = relationCode(slideOwner, nSlide); slideHasFriendly = slideHasFriendly || isFriendly(rel); slideHasEmbargo = slideHasEmbargo || isEmbargo(rel); } }
// Check if we're on a border in the FROM state (retreating side)
uint fromSlideState = prevStateAtTex(slideTexFront);
uint fromSlideOwner = fromSlideState & 0xFFFu;
bool fromSlideDefended = (fromSlideState & 0x1000u) != 0u;
bool fromIsBorderAtSlide = false;
uint fromOtherAtSlide = 0u;
uint nFromSlide;
ivec2 nFromSlideTex;
nFromSlideTex = clamp(slideTexFront + ivec2(1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
nFromSlide = texelFetch(u_prevOwner, nFromSlideTex, 0).r & 0xFFFu;
if (nFromSlide != fromSlideOwner) { fromIsBorderAtSlide = true; if (nFromSlide != 0u) fromOtherAtSlide = nFromSlide; }
nFromSlideTex = clamp(slideTexFront + ivec2(-1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
nFromSlide = texelFetch(u_prevOwner, nFromSlideTex, 0).r & 0xFFFu;
if (nFromSlide != fromSlideOwner) { fromIsBorderAtSlide = true; if (nFromSlide != 0u) fromOtherAtSlide = nFromSlide; }
nFromSlideTex = clamp(slideTexFront + ivec2(0, 1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
nFromSlide = texelFetch(u_prevOwner, nFromSlideTex, 0).r & 0xFFFu;
if (nFromSlide != fromSlideOwner) { fromIsBorderAtSlide = true; if (nFromSlide != 0u) fromOtherAtSlide = nFromSlide; }
nFromSlideTex = clamp(slideTexFront + ivec2(0, -1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
nFromSlide = texelFetch(u_prevOwner, nFromSlideTex, 0).r & 0xFFFu;
if (nFromSlide != fromSlideOwner) { fromIsBorderAtSlide = true; if (nFromSlide != 0u) fromOtherAtSlide = nFromSlide; }
// Draw border stripe: check both expanding (TO) and retreating (FROM) sides
float stripeWidth = u_debugDisableAllBorders ? 0.0 : 0.5;
bool isStripe = abs(frontDist) <= stripeWidth;
bool drawExpandingBorder =
isStripe && slideIsBorder && slideOwner != 0u && frontDist > 0.0;
bool drawRetreatingBorder =
isStripe && fromIsBorderAtSlide && fromSlideOwner != 0u && frontDist <= 0.0;
if (!u_debugDisableAllBorders && (drawExpandingBorder || drawRetreatingBorder)) {
uint stripeOwner = drawExpandingBorder ? slideOwner : fromSlideOwner;
uint stripeOther = drawExpandingBorder ? slideOther : fromOtherAtSlide;
if (u_debugStripeFixedColors) {
// Debug mode: Use fixed colors
if (drawExpandingBorder) {
// Expanding: bright red
color = vec3(1.0, float(stripeOwner) / 255.0, 0.0);
} else {
// Retreating: bright blue
color = vec3(0.0, float(stripeOwner) / 255.0, 1.0);
}
} else {
// Normal mode: Use actual border colors
if (stripeOwner != 0u) {
vec4 borderBase = texelFetch(
u_palette,
ivec2(int(stripeOwner) * 2 + 1, 0),
0
);
bool stripeFriendly = false;
bool stripeEmbargo = false;
if (stripeOther != 0u) {
uint rel = relationCode(stripeOwner, stripeOther);
stripeFriendly = isFriendly(rel);
stripeEmbargo = isEmbargo(rel);
}
bool stripeDefended = drawExpandingBorder
? slideIsDefended
: fromSlideDefended;
vec3 bColor = applyBorderTint(
borderBase.rgb,
stripeFriendly,
stripeEmbargo
);
bColor = applyDefended(bColor, stripeDefended, slideTexFront);
color = bColor;
}
}
if (isEmbargo(rel)) {
bColor = bColor * (1.0 - BORDER_TINT_RATIO) + EMBARGO_TINT_TARGET * BORDER_TINT_RATIO;
}
}
bColor = applyDefended(bColor, isDefended, toTex);
toColor = bColor;
}
} else if (frontDist > stripeWidth) {
// Front has passed; show the new fill/border at the shifted position
vec2 slideCoordFill = mapCoord - dir * (dispLen * (1.0 - t));
ivec2 slideTexFill = clamp(ivec2(slideCoordFill), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
// Overwrite (no blending)
color = toColor;
uint fillState = texelFetch(u_state, slideTexFill, 0).r;
uint fillOwner = fillState & 0xFFFu;
bool fillHasFallout = (fillState & 0x2000u) != 0u;
bool fillIsDefended = (fillState & 0x1000u) != 0u;
bool fillIsBorder = false;
bool fillHasFriendly = false;
bool fillHasEmbargo = false;
uint fillOther = 0u;
uint nFill;
ivec2 nFillTex;
nFillTex = clamp(slideTexFill + ivec2(1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
nFill = texelFetch(u_state, nFillTex, 0).r & 0xFFFu;
if (nFill != fillOwner) { fillIsBorder = true; if (nFill != 0u) { fillOther = nFill; uint rel = relationCode(fillOwner, nFill); fillHasFriendly = fillHasFriendly || isFriendly(rel); fillHasEmbargo = fillHasEmbargo || isEmbargo(rel); } }
nFillTex = clamp(slideTexFill + ivec2(-1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
nFill = texelFetch(u_state, nFillTex, 0).r & 0xFFFu;
if (nFill != fillOwner) { fillIsBorder = true; if (nFill != 0u) { fillOther = nFill; uint rel = relationCode(fillOwner, nFill); fillHasFriendly = fillHasFriendly || isFriendly(rel); fillHasEmbargo = fillHasEmbargo || isEmbargo(rel); } }
nFillTex = clamp(slideTexFill + ivec2(0, 1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
nFill = texelFetch(u_state, nFillTex, 0).r & 0xFFFu;
if (nFill != fillOwner) { fillIsBorder = true; if (nFill != 0u) { fillOther = nFill; uint rel = relationCode(fillOwner, nFill); fillHasFriendly = fillHasFriendly || isFriendly(rel); fillHasEmbargo = fillHasEmbargo || isEmbargo(rel); } }
nFillTex = clamp(slideTexFill + ivec2(0, -1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
nFill = texelFetch(u_state, nFillTex, 0).r & 0xFFFu;
if (nFill != fillOwner) { fillIsBorder = true; if (nFill != 0u) { fillOther = nFill; uint rel = relationCode(fillOwner, nFill); fillHasFriendly = fillHasFriendly || isFriendly(rel); fillHasEmbargo = fillHasEmbargo || isEmbargo(rel); } }
vec3 toColor = baseTerrainColor;
if (fillOwner != 0u) {
vec4 toBase = texelFetch(u_palette, ivec2(int(fillOwner) * 2, 0), 0);
vec4 toBorderBase = texelFetch(
u_palette,
ivec2(int(fillOwner) * 2 + 1, 0),
0
);
bool toPrimary = patternIsPrimary(fillOwner, slideTexFill);
vec3 toPatternColor = toPrimary ? toBase.rgb : toBorderBase.rgb;
toColor = mix(baseTerrainColor, toPatternColor, u_alpha);
if (!u_debugDisableAllBorders && !u_debugDisableStaticBorders && fillIsBorder) {
vec3 bColor = applyBorderTint(
toBorderBase.rgb,
fillHasFriendly,
fillHasEmbargo
);
bColor = applyDefended(bColor, fillIsDefended, slideTexFill);
toColor = bColor;
}
} else if (fillHasFallout) {
toColor = mix(baseTerrainColor, u_fallout.rgb, u_alpha);
}
color = toColor;
}
// If frontDist < -stripeWidth, we're ahead of the front, so keep fromColor (already set).
}
}
}