diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 6229de04e..de4510085 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -413,9 +413,11 @@ function mountWebGLFrameLoop( const frameData = gameView.frameData(); view.uploadTileAndTrailState(frameData.tileState, frameData.trailState); - // Structures and railroads normally skip GPU upload unless marked dirty, now force + // Structures, railroads and relations normally skip GPU upload unless + // marked dirty, now force view.updateStructures(frameData.units as Map); view.uploadRailroadState(frameData.railroadState); + view.updateRelations(frameData.relationMatrix, frameData.relationSize); builder.update(gameView); }; diff --git a/src/client/render/frame/Upload.ts b/src/client/render/frame/Upload.ts index 2a835cec5..2344af2f4 100644 --- a/src/client/render/frame/Upload.ts +++ b/src/client/render/frame/Upload.ts @@ -105,7 +105,11 @@ export function uploadFrameData( view.updateNames(frame.names, frame.players, false, frame.playerStatus); // --- Relations --- - view.updateRelations(frame.relationMatrix, frame.relationSize); + // Gated: updateRelations triggers a full-map border recompute downstream, + // so only push when the matrix was actually rebuilt this tick. + if (frame.relationsDirty) { + view.updateRelations(frame.relationMatrix, frame.relationSize); + } // --- Alliance clusters (SAM pass) --- view.setSAMAllianceClusters(frame.allianceClusters); diff --git a/src/client/render/frame/derive/PlayerStatus.ts b/src/client/render/frame/derive/PlayerStatus.ts index ed0b1b6b4..0f750de68 100644 --- a/src/client/render/frame/derive/PlayerStatus.ts +++ b/src/client/render/frame/derive/PlayerStatus.ts @@ -74,6 +74,24 @@ export function computePlayerStatus( } } + // Nukes: single pass over units → per-owner flags (avoids the + // O(players × units) scan of checking every unit per player). + // Shown during replay too, except the nukeTargetsMe flag. + const nukeActiveOwners = new Set(); + const nukeTargetsMeOwners = new Set(); + for (const u of units.values()) { + if (!u.isActive || !NUKE_ACTIVE_TYPES.has(u.unitType)) continue; + nukeActiveOwners.add(u.ownerID); + if ( + localPlayerSmallID > 0 && + tileState !== undefined && + u.targetTile !== null && + (tileState[u.targetTile] & OWNER_MASK) === localPlayerSmallID + ) { + nukeTargetsMeOwners.add(u.ownerID); + } + } + for (const ps of players.values()) { if (!ps.isAlive) continue; const sid = ps.smallID; @@ -83,8 +101,8 @@ export function computePlayerStatus( const traitorRemainingTicks = ps.traitorRemainingTicks; // Relative flags - let nukeActive = false; - let nukeTargetsMe = false; + const nukeActive = nukeActiveOwners.has(sid); + const nukeTargetsMe = nukeTargetsMeOwners.has(sid); let alliance = false; let target = false; let embargo = false; @@ -92,26 +110,6 @@ export function computePlayerStatus( let allianceFraction = 0; let allianceRemainingTicks = 0; - // Nukes: show during replay too, except the nukeTargetsMe flag - for (const u of units.values()) { - if ( - u.ownerID === sid && - u.isActive && - NUKE_ACTIVE_TYPES.has(u.unitType) - ) { - nukeActive = true; - if ( - localPlayerSmallID > 0 && - tileState !== undefined && - u.targetTile !== null && - (tileState[u.targetTile] & OWNER_MASK) === localPlayerSmallID - ) { - nukeTargetsMe = true; - } - if (nukeTargetsMe) break; - } - } - // Flags which are only meaningful when there's a local player, // and we're not looking at the local player itself. if (localPlayer !== undefined && sid !== localPlayerSmallID) { diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index 7a3b918e7..3ae5617c3 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -1033,6 +1033,11 @@ export class GPURenderer { this.borderPass.markGlobalDirty(); this.defenseCoveragePass.markDirty(); } + // Heat decay only runs while fallout is in play — (re)activate whenever a + // fallout bit flipped in the tile state that just reached the GPU. + if (this.territoryPass.consumeFalloutTouched()) { + this.heatManager.activate(); + } this.trailPass.flushTexture(); this.heatManager.updateHeat(); } diff --git a/src/client/render/gl/passes/TerritoryPass.ts b/src/client/render/gl/passes/TerritoryPass.ts index e83e07c41..8ad77a794 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 { TILE_DEFINES } from "../utils/TileCodec"; +import { FALLOUT_BIT, 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"; @@ -64,6 +64,13 @@ export class TerritoryPass { private cpuTileState: Uint16Array; private tilesDirty = false; + /** + * True when a tile's fallout bit flipped since the last consume (or a full + * state replacement happened, which may contain fallout). The renderer uses + * this to activate the heat-decay pass only while fallout is in play. + */ + private falloutTouched = false; + /** * True after a full state replacement (initial load / seek). flushTileTexture * uploads the full cpuTileState via texSubImage2D and discards any queued @@ -200,6 +207,7 @@ export class TerritoryPass { this.scatter.clear(); this.fullUploadPending = true; this.tilesDirty = true; + this.falloutTouched = true; // conservative: replaced state may have fallout } /** @@ -238,6 +246,9 @@ 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) { + this.falloutTouched = true; + } ts[ref] = state; if (!pending) { const x = ref % w; @@ -269,6 +280,9 @@ 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) { + this.falloutTouched = true; + } ts[ref] = state; if (!pending) { const x = ref % w; @@ -289,6 +303,20 @@ export class TerritoryPass { this.currentBucket = 0; } + // --------------------------------------------------------------------------- + // Queries + // --------------------------------------------------------------------------- + + /** + * Returns true (and resets) if any fallout bit flipped since the last call. + * Checked by the renderer each frame to (re)activate heat decay. + */ + consumeFalloutTouched(): boolean { + const touched = this.falloutTouched; + this.falloutTouched = false; + return touched; + } + // --------------------------------------------------------------------------- // GPU flush + draw // --------------------------------------------------------------------------- diff --git a/src/client/render/gl/utils/HeatManager.ts b/src/client/render/gl/utils/HeatManager.ts index ce4a335e7..9f949ee67 100644 --- a/src/client/render/gl/utils/HeatManager.ts +++ b/src/client/render/gl/utils/HeatManager.ts @@ -46,8 +46,10 @@ export class HeatManager { private pendingDecay = 0; /** * True when heat may be non-zero anywhere — gates the decay pass. - * Set true on each game tick (shader may detect new fallout transitions). - * Set false once accumulated decay since last activation exceeds 255 (fully drained). + * Set true via activate() whenever a tile's fallout bit flips (or a full + * state replacement happens). Set false once accumulated decay since last + * activation exceeds 255 (fully drained). While false, updateHeat() does no + * GPU work at all. */ private heatActive = false; /** Accumulated decay since heatActive was last set true. */ @@ -164,15 +166,13 @@ export class HeatManager { return; } - // 2. Skip decay pass when nothing to do — no pending decay and heat already settled. - // Still blit tileTex→prevTileTex when a tick fired (pendingDecay > 0) so transition - // detection stays accurate if heat activates later. - if (!this.heatActive && this.pendingDecay === 0) return; + // 2. Inactive: no heat anywhere, and no fallout bits can change without + // activate() being called first (TerritoryPass flags every fallout-bit + // flip before the tile flush reaches the GPU). prevTileTex can go stale + // in owner bits only, which the transition test ignores — so skip all GPU + // work, including the prev-tile blit. if (!this.heatActive) { - // Tick fired but no heat — just keep prevTileTex in sync and bail. - this.blitTileToPrev(); this.pendingDecay = 0; - gl.bindFramebuffer(gl.FRAMEBUFFER, null); return; } @@ -226,11 +226,16 @@ export class HeatManager { /** Accumulate heat decay for one game tick. */ decayHeat(): void { this.pendingDecay += this.settings.falloutBloom.heatDecayPerTick; - // A tick fired — the shader may detect new fallout transitions, so heat is potentially active. - if (!this.heatActive) { - this.heatActive = true; - this.decayAccumulated = 0; - } + } + + /** + * Activate the heat pipeline: a fallout bit flipped, so the decay pass must + * run (transition detection stamps fresh heat / clears recaptured tiles). + * Resets the drain window — fresh heat needs a full 255 of decay again. + */ + activate(): void { + this.heatActive = true; + this.decayAccumulated = 0; } // --------------------------------------------------------------------------- diff --git a/src/client/render/types/FrameData.ts b/src/client/render/types/FrameData.ts index bb931e0af..abc10a48a 100644 --- a/src/client/render/types/FrameData.ts +++ b/src/client/render/types/FrameData.ts @@ -58,6 +58,12 @@ export interface FrameData { readonly playerStatus: ReadonlyMap; readonly relationMatrix: Uint8Array; readonly relationSize: number; + /** + * True when relationMatrix was rebuilt this tick (alliance/embargo change). + * Consumers skip the GPU upload — and the full-map border recompute it + * triggers — when false. + */ + readonly relationsDirty: boolean; readonly allianceClusters: ReadonlyMap; readonly nukeTelegraphs: NukeTelegraphData[]; readonly attackRings: AttackRingInput[]; diff --git a/src/client/view/GameView.ts b/src/client/view/GameView.ts index 2efcdc9b5..9a9596c5c 100644 --- a/src/client/view/GameView.ts +++ b/src/client/view/GameView.ts @@ -195,6 +195,7 @@ export class GameView implements GameMap { playerStatus: new Map(), relationMatrix: new Uint8Array(0), relationSize: 0, + relationsDirty: false, allianceClusters: new Map(), nukeTelegraphs: [], attackRings: [], @@ -563,12 +564,17 @@ export class GameView implements GameMap { // change rarely (teams only when a player is added) — recompute only // when one of those inputs arrived this tick. buildRelationMatrix // writes into a reusable module-level buffer, so skipping the call - // leaves f.relationMatrix's contents intact. + // leaves f.relationMatrix's contents intact. f.relationsDirty lets the + // upload layer skip the GPU push (and the full-map border recompute it + // triggers) on unchanged ticks. if (this._relationsDirty) { this._relationsDirty = false; const rel = buildRelationMatrix(this._playerStates, this._teams); f.relationMatrix = rel.matrix; f.relationSize = rel.size; + f.relationsDirty = true; + } else { + f.relationsDirty = false; } if (this._clustersDirty) { this._clustersDirty = false;