diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index b27978a52..f42344763 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -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}`, diff --git a/src/client/graphics/layers/TerritoryWebGLRenderer.ts b/src/client/graphics/layers/TerritoryWebGLRenderer.ts index efaa948f6..e62e78d91 100644 --- a/src/client/graphics/layers/TerritoryWebGLRenderer.ts +++ b/src/client/graphics/layers/TerritoryWebGLRenderer.ts @@ -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). + } } }