mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 08:20:50 +00:00
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:
@@ -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 {
|
||||
/**
|
||||
* 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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user