From 1ad71b9cfae431ced2aa25dacc2cae56f5fc3ab8 Mon Sep 17 00:00:00 2001 From: Evan Date: Tue, 16 Jun 2026 08:58:29 -0700 Subject: [PATCH] Fix hover-highlight inner border lagging during tile changes (#4303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- src/client/render/gl/Renderer.ts | 4 +- .../render/gl/passes/BorderComputePass.ts | 15 +++-- .../render/gl/passes/BorderScatterPass.ts | 62 +++++++++++++++---- src/client/render/gl/passes/TerritoryPass.ts | 24 ++++--- 4 files changed, 79 insertions(+), 26 deletions(-) diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index 16b90a3a4..e82ae1126 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -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; diff --git a/src/client/render/gl/passes/BorderComputePass.ts b/src/client/render/gl/passes/BorderComputePass.ts index 414eb7ddf..c9ba76f37 100644 --- a/src/client/render/gl/passes/BorderComputePass.ts +++ b/src/client/render/gl/passes/BorderComputePass.ts @@ -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). */ diff --git a/src/client/render/gl/passes/BorderScatterPass.ts b/src/client/render/gl/passes/BorderScatterPass.ts index 65851e125..1f5638c9c 100644 --- a/src/client/render/gl/passes/BorderScatterPass.ts +++ b/src/client/render/gl/passes/BorderScatterPass.ts @@ -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 { diff --git a/src/client/render/gl/passes/TerritoryPass.ts b/src/client/render/gl/passes/TerritoryPass.ts index 8ad77a794..5b3547264 100644 --- a/src/client/render/gl/passes/TerritoryPass.ts +++ b/src/client/render/gl/passes/TerritoryPass.ts @@ -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;