Fix hover-highlight inner border lagging during tile changes (#4303)

## Problem

When you hover over a territory, it highlights with a band that is
`highlightThicken` (default **2**) tiles deep — the edge plus 2 interior
rings, computed via a Chebyshev expansion in `border-compute.frag.glsl`.

Starting a hover triggers a full border recompute, which paints the band
correctly. But while you keep hovering and tiles change owner (territory
growing/shrinking, combat at the front), only the cheap **incremental**
scatter path runs. `BorderScatterPass.pushWithNeighbors` repainted only
the changed tile **+ its 4 cardinal neighbors** (radius 1) — fine for
normal borders, but not for the highlight band. A changed tile affects
the thickening of *every* highlight-owner tile within `highlightThicken`
of it, and those interior tiles were never repainted, so the **inner
edge of the highlight band stayed stale** ("the inside border is not
getting updated"). This was a documented trade-off in the class comment.

## Fix

When a highlight is active, `pushWithNeighbors` now repaints a Chebyshev
**box of radius `highlightThicken`** around each changed tile (the box
subsumes the cardinal cross, so normal borders still update). With no
highlight active it stays on the cheap 5-point cross, preserving the
pass's O(dirty-tiles) scaling. The extra cost (~25 vs 5 points/tile at
default) only applies while actually hovering.

## Testing

Hover over a territory while it grows/shrinks (early-game expansion or a
war front) and confirm the inner edge of the highlight band now tracks
the moving border instead of lagging.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-06-16 08:58:29 -07:00
committed by GitHub
parent 0639cdb29b
commit 1ad71b9cfa
4 changed files with 79 additions and 26 deletions
+2 -2
View File
@@ -350,8 +350,8 @@ export class GPURenderer {
// just the affected tiles instead of rebuilding the whole map. A tile
// changing owner can also flip its defense-coverage flag (same-owner test),
// so mark the coverage stale too — one coalesced re-stamp happens per frame.
this.territoryPass.setBorderPatchConsumer((x, y) => {
this.borderPass.patchTile(x, y);
this.territoryPass.setBorderPatchConsumer((x, y, prevOwner, newOwner) => {
this.borderPass.patchTile(x, y, prevOwner, newOwner);
this.defenseCoveragePass.markTileDirty(x, y);
});
// Territory fill darkens on interior tiles defended by a same-owner post;
@@ -187,13 +187,16 @@ export class BorderComputePass {
}
/**
* Notify that one tile changed owner. Schedules incremental border recompute
* for that tile + its 4 cardinal neighbors. Cheap: ~5 points per call.
* Caller is responsible for ensuring tileTex contains the new state before
* the next draw — TerritoryPass.flushTileTexture takes care of that.
* Notify that one tile changed owner (from `prevOwner` to `newOwner`).
* Schedules incremental border recompute for that tile + its 4 cardinal
* neighbors — or, when the change touches the highlighted owner, a wider box
* so the highlight band's inner edge keeps up. Cheap: ~5 points per call in
* the common case. Caller is responsible for ensuring tileTex contains the
* new state before the next draw — TerritoryPass.flushTileTexture takes care
* of that.
*/
patchTile(x: number, y: number): void {
this.scatter.pushWithNeighbors(x, y);
patchTile(x: number, y: number, prevOwner: number, newOwner: number): void {
this.scatter.pushWithNeighbors(x, y, prevOwner, newOwner);
}
/** The border buffer texture (RG8, tile resolution). */
@@ -13,10 +13,11 @@
* border shader makes the neighbors' results depend on this tile's ownership.
* Use `pushWithNeighbors` to do that expansion automatically.
*
* Highlight-thicken rings within `uHighlightThicken` of a changed tile are
* NOT incrementally repainted — they'll lag visually until the next full
* recompute (which fires on highlight / relation / defense changes). That
* artifact is small and short-lived; for live combat it's a fair trade.
* When a tile is gained or lost by the highlighted owner, it also affects the
* highlight thickening of nearby highlight-owner tiles (an N-tile Chebyshev
* expansion), so `pushWithNeighbors` widens the repaint to that radius for
* those tiles only — otherwise the inner edge of the highlight band would lag
* until the next full recompute.
*/
import type { RenderSettings } from "../RenderSettings";
@@ -122,13 +123,52 @@ export class BorderScatterPass {
this.patchCount++;
}
/** Queue the tile + its 4 cardinal neighbors (clipped to map bounds). */
pushWithNeighbors(x: number, y: number): void {
this.push(x, y);
if (x > 0) this.push(x - 1, y);
if (x < this.mapW - 1) this.push(x + 1, y);
if (y > 0) this.push(x, y - 1);
if (y < this.mapH - 1) this.push(x, y + 1);
/**
* Queue the tile + the neighborhood whose border value depends on it
* (clipped to map bounds). `prevOwner`/`newOwner` are the tile's owner before
* and after the change.
*
* Normal borders only need the 4 cardinal neighbors (the shader's border
* test is cardinal-only). But the highlight thickening is an N-tile Chebyshev
* expansion: a tile being gained or lost by the highlighted owner affects the
* thickening of every highlight-owner tile within `highlightThicken` of it.
* In that case — and only that case — repaint the whole box so the inner edge
* of the highlight band tracks the change instead of lagging until the next
* full recompute. Changes elsewhere on the map don't touch the band, so they
* keep the cheap cardinal cross.
*/
pushWithNeighbors(
x: number,
y: number,
prevOwner: number,
newOwner: number,
): void {
const touchesHighlight =
this.highlightOwner !== 0 &&
(prevOwner === this.highlightOwner || newOwner === this.highlightOwner);
if (!touchesHighlight) {
this.push(x, y);
if (x > 0) this.push(x - 1, y);
if (x < this.mapW - 1) this.push(x + 1, y);
if (y > 0) this.push(x, y - 1);
if (y < this.mapH - 1) this.push(x, y + 1);
return;
}
const r = Math.max(
1,
Math.floor(this.settings.mapOverlay.highlightThicken),
);
const x0 = Math.max(0, x - r);
const x1 = Math.min(this.mapW - 1, x + r);
const y0 = Math.max(0, y - r);
const y1 = Math.min(this.mapH - 1, y + r);
for (let yy = y0; yy <= y1; yy++) {
for (let xx = x0; xx <= x1; xx++) {
this.push(xx, yy);
}
}
}
get count(): number {
+17 -7
View File
@@ -16,7 +16,7 @@ import type { TilePair } from "../../types";
import type { RenderSettings } from "../RenderSettings";
import { getPaletteSize } from "../utils/ColorUtils";
import { createMapQuad, createProgram, shaderSrc } from "../utils/GlUtils";
import { FALLOUT_BIT, TILE_DEFINES } from "../utils/TileCodec";
import { FALLOUT_BIT, OWNER_MASK, TILE_DEFINES } from "../utils/TileCodec";
import overlayVertSrc from "../shaders/map-overlay/overlay.vert.glsl?raw";
import territoryFragSrc from "../shaders/map-overlay/territory.frag.glsl?raw";
@@ -89,7 +89,9 @@ export class TerritoryPass {
* incrementally repaint affected tiles instead of rebuilding the whole map.
* Wired by the renderer to `borderPass.patchTile`.
*/
private borderPatchConsumer: ((x: number, y: number) => void) | null = null;
private borderPatchConsumer:
| ((x: number, y: number, prevOwner: number, newOwner: number) => void)
| null = null;
/**
* Drip buckets — round-robin staggering of tile updates across render frames.
@@ -216,7 +218,9 @@ export class TerritoryPass {
* hooks this to `borderPass.patchTile` so border recompute scales with the
* number of changed tiles instead of full map area.
*/
setBorderPatchConsumer(fn: (x: number, y: number) => void): void {
setBorderPatchConsumer(
fn: (x: number, y: number, prevOwner: number, newOwner: number) => void,
): void {
this.borderPatchConsumer = fn;
}
@@ -246,7 +250,8 @@ export class TerritoryPass {
for (let i = 0; i < bucket.length; i += 2) {
const ref = bucket[i];
const state = bucket[i + 1];
if (((ts[ref] ^ state) & FALLOUT_BIT) !== 0) {
const prev = ts[ref];
if (((prev ^ state) & FALLOUT_BIT) !== 0) {
this.falloutTouched = true;
}
ts[ref] = state;
@@ -254,7 +259,9 @@ export class TerritoryPass {
const x = ref % w;
const y = (ref - x) / w;
this.scatter.push(x, y, state);
if (borderFn) borderFn(x, y);
if (borderFn) {
borderFn(x, y, prev & OWNER_MASK, state & OWNER_MASK);
}
}
}
bucket.length = 0;
@@ -280,7 +287,8 @@ export class TerritoryPass {
for (let i = 0; i < bucket.length; i += 2) {
const ref = bucket[i];
const state = bucket[i + 1];
if (((ts[ref] ^ state) & FALLOUT_BIT) !== 0) {
const prev = ts[ref];
if (((prev ^ state) & FALLOUT_BIT) !== 0) {
this.falloutTouched = true;
}
ts[ref] = state;
@@ -288,7 +296,9 @@ export class TerritoryPass {
const x = ref % w;
const y = (ref - x) / w;
this.scatter.push(x, y, state);
if (borderFn) borderFn(x, y);
if (borderFn) {
borderFn(x, y, prev & OWNER_MASK, state & OWNER_MASK);
}
}
}
bucket.length = 0;