From 4ce8ec14ccb4827f51c98375c7c4da9f3324bab9 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Sun, 11 Jan 2026 21:46:31 +0100 Subject: [PATCH] accept delay, use triplebuffer, Refactor contest management in TerritoryLayer and TerritoryWebGLRenderer - Updated contest duration handling to use ticks instead of milliseconds for improved precision. - Introduced new tick-based state management for contest updates and rendering. - Enhanced interpolation logic for smoother transitions between contest states. - Removed obsolete smooth state handling and related properties to streamline code. - Added support for older contest states in the WebGL renderer for better visual fidelity. --- src/client/graphics/layers/TerritoryLayer.ts | 154 ++++----- .../graphics/layers/TerritoryWebGLRenderer.ts | 293 ++++++++++++++++-- 2 files changed, 346 insertions(+), 101 deletions(-) diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 8cc11548b..f4f37256a 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -20,7 +20,7 @@ import { TerritoryWebGLRenderer } from "./TerritoryWebGLRenderer"; const CONTEST_ID_MASK = 0x7fff; const CONTEST_ATTACKER_EVER_BIT = 0x8000; const CONTEST_TIME_WRAP = 32768; -const DEFAULT_CONTEST_DURATION_MS = 200; +const DEFAULT_CONTEST_DURATION_TICKS = 2; const CONTEST_SPEED_TPS_MAX = 20; const CONTEST_SPEED_EMA_ALPHA = 0.8; const CONTEST_SPEED_DECAY_HALFLIFE_MS = 100; @@ -65,7 +65,7 @@ export class TerritoryLayer implements Layer { private lastFocusedPlayer: PlayerView | null = null; private lastMyPlayerSmallId: number | null = null; private lastPaletteSignature: string | null = null; - private contestDurationMs = DEFAULT_CONTEST_DURATION_MS; + private contestDurationTicks = DEFAULT_CONTEST_DURATION_TICKS; private contestActive = false; private contestNextId = 1; private contestFreeIds: number[] = []; @@ -74,10 +74,15 @@ export class TerritoryLayer implements Layer { private contestAttackers: Uint16Array | null = null; private contestTileIndices: Int32Array | null = null; private contestComponents = new Map(); - private smoothDurationMs = 100; - private smoothActive = false; - private smoothStartMs = 0; - private smoothSnapshotPending = false; + private tickSnapshotPending = false; + private tickTimeMsCurrent = 0; + private tickTimeMsPrev = 0; + private tickTimeMsOlder = 0; + private tickNumberCurrent: number | null = null; + private tickNumberPrev: number | null = null; + private tickNumberOlder: number | null = null; + private interpolationDelayMs = 100; + private lastInterpolationPair: "prevCurrent" | "olderPrev" = "prevCurrent"; private contestSpeedDeltas = new Map(); private contestSpeedLastUpdateMs = 0; @@ -114,17 +119,27 @@ export class TerritoryLayer implements Layer { } this.refreshPaletteIfNeeded(); + const tickNumber = this.game.ticks(); + if (this.tickNumberCurrent !== tickNumber) { + this.tickNumberOlder = this.tickNumberPrev; + this.tickNumberPrev = this.tickNumberCurrent; + this.tickNumberCurrent = tickNumber; + + this.tickTimeMsOlder = this.tickTimeMsPrev; + this.tickTimeMsPrev = this.tickTimeMsCurrent; + this.tickTimeMsCurrent = now; + + if (this.territoryRenderer) { + this.tickSnapshotPending = true; + } + } + this.game.recentlyUpdatedTiles().forEach((t) => this.markTile(t)); const ownerUpdates = this.game.recentlyUpdatedOwnerTiles(); - if (ownerUpdates.length > 0) { - if (this.territoryRenderer) { - this.smoothSnapshotPending = true; - } - this.smoothStartMs = now; - this.smoothActive = true; - } this.contestSpeedDeltas.clear(); - this.applyContestChanges(ownerUpdates, now); + const nowTickPacked = this.packContestTick(this.game.ticks()); + this.applyContestChanges(ownerUpdates, nowTickPacked); + this.updateContestState(nowTickPacked); this.updateContestSpeeds(now); this.updateContestStrengths(); const updates = this.game.updatesSinceLastTick(); @@ -389,7 +404,6 @@ export class TerritoryLayer implements Layer { this.configureRenderers(); this.ensureContestScratch(); this.syncContestStateToRenderer(); - this.syncSmoothStateToRenderer(); // Add a second canvas for highlights this.highlightCanvas = document.createElement("canvas"); @@ -450,12 +464,11 @@ export class TerritoryLayer implements Layer { return; } const now = this.nowMs(); - if (this.smoothSnapshotPending) { + if (this.tickSnapshotPending) { this.territoryRenderer.snapshotStateForSmoothing(); - this.smoothSnapshotPending = false; + this.tickSnapshotPending = false; } - this.updateSmoothState(now); - this.updateContestState(now); + this.updateInterpolationState(now); const renderTerritoryStart = FrameProfiler.start(); this.territoryRenderer.setViewSize( @@ -582,20 +595,38 @@ export class TerritoryLayer implements Layer { } } - private updateSmoothState(now: number) { + private updateInterpolationState(now: number) { if (!this.territoryRenderer) { return; } - let progress = 1; - if (this.smoothActive) { - const elapsed = now - this.smoothStartMs; - progress = Math.max(0, Math.min(1, elapsed / this.smoothDurationMs)); - if (progress >= 1) { - this.smoothActive = false; - } + + if (this.tickTimeMsPrev <= 0 || this.tickTimeMsCurrent <= 0) { + this.lastInterpolationPair = "prevCurrent"; + this.territoryRenderer.setInterpolationPair("prevCurrent"); + this.territoryRenderer.setSmoothProgress(1); + this.territoryRenderer.setSmoothEnabled(false); + return; } + + const renderTime = now - this.interpolationDelayMs; + + let pair: "prevCurrent" | "olderPrev" = "prevCurrent"; + let fromTime = this.tickTimeMsPrev; + let toTime = this.tickTimeMsCurrent; + + if (this.tickTimeMsOlder > 0 && renderTime < this.tickTimeMsPrev) { + pair = "olderPrev"; + fromTime = this.tickTimeMsOlder; + toTime = this.tickTimeMsPrev; + } + + const denom = Math.max(1, Math.min(250, toTime - fromTime)); + const progress = Math.max(0, Math.min(1, (renderTime - fromTime) / denom)); + + this.lastInterpolationPair = pair; + this.territoryRenderer.setInterpolationPair(pair); this.territoryRenderer.setSmoothProgress(progress); - this.territoryRenderer.setSmoothEnabled(this.smoothActive); + this.territoryRenderer.setSmoothEnabled(true); } private recordContestSpeed(componentId: number) { @@ -605,13 +636,12 @@ export class TerritoryLayer implements Layer { private applyContestChanges( changes: Array<{ tile: TileRef; previousOwner: number; newOwner: number }>, - now: number, + nowTickPacked: number, ) { if (!this.territoryRenderer || changes.length === 0) { return; } this.ensureContestScratch(); - const nowPacked = this.packContestTime(now); for (const change of changes) { if (change.newOwner === change.previousOwner) { @@ -624,7 +654,7 @@ export class TerritoryLayer implements Layer { tile, change.previousOwner, change.newOwner, - nowPacked, + nowTickPacked, ); if (component) { this.recordContestSpeed(component.id); @@ -639,7 +669,7 @@ export class TerritoryLayer implements Layer { tile, change.previousOwner, change.newOwner, - nowPacked, + nowTickPacked, ); if (newComponent) { this.recordContestSpeed(newComponent.id); @@ -660,8 +690,8 @@ export class TerritoryLayer implements Layer { component.id, attackerEver, ); - component.lastActivityPacked = nowPacked; - this.territoryRenderer.setContestTime(component.id, nowPacked); + component.lastActivityPacked = nowTickPacked; + this.territoryRenderer.setContestTime(component.id, nowTickPacked); this.recordContestSpeed(component.id); } else { this.removeTileFromComponent(tile, component); @@ -669,7 +699,7 @@ export class TerritoryLayer implements Layer { tile, change.previousOwner, change.newOwner, - nowPacked, + nowTickPacked, ); if (newComponent) { this.recordContestSpeed(newComponent.id); @@ -777,13 +807,15 @@ export class TerritoryLayer implements Layer { return total; } - private updateContestState(now: number) { + private updateContestState(nowTickPacked: number) { if (!this.territoryRenderer) { return; } this.ensureContestScratch(); - const nowPacked = this.packContestTime(now); - this.territoryRenderer.setContestNow(nowPacked, this.contestDurationMs); + this.territoryRenderer.setContestNow( + nowTickPacked, + this.contestDurationTicks, + ); if (!this.contestActive) { return; @@ -792,10 +824,10 @@ export class TerritoryLayer implements Layer { const expired: ContestComponent[] = []; for (const component of this.contestComponents.values()) { const elapsed = this.contestElapsed( - nowPacked, + nowTickPacked, component.lastActivityPacked, ); - if (elapsed >= this.contestDurationMs) { + if (elapsed >= this.contestDurationTicks) { expired.push(component); } } @@ -809,7 +841,7 @@ export class TerritoryLayer implements Layer { tile: TileRef, defender: number, attacker: number, - nowPacked: number, + nowTickPacked: number, ): ContestComponent | null { if (attacker === defender || attacker === 0 || defender === 0) { return null; @@ -817,7 +849,7 @@ export class TerritoryLayer implements Layer { const neighbors = this.collectNeighborComponents(tile, attacker, defender); let component: ContestComponent; if (neighbors.length === 0) { - component = this.createContestComponent(attacker, defender, nowPacked); + component = this.createContestComponent(attacker, defender, nowTickPacked); } else { component = neighbors[0]; for (let i = 1; i < neighbors.length; i++) { @@ -826,8 +858,8 @@ export class TerritoryLayer implements Layer { } this.addTileToComponent(tile, component, true); - component.lastActivityPacked = nowPacked; - this.territoryRenderer?.setContestTime(component.id, nowPacked); + component.lastActivityPacked = nowTickPacked; + this.territoryRenderer?.setContestTime(component.id, nowTickPacked); return component; } @@ -858,14 +890,14 @@ export class TerritoryLayer implements Layer { private createContestComponent( attacker: number, defender: number, - nowPacked: number, + nowTickPacked: number, ): ContestComponent { const id = this.allocateContestComponentId(); const component: ContestComponent = { id, attacker, defender, - lastActivityPacked: nowPacked, + lastActivityPacked: nowTickPacked, tiles: [], speed: 0, strength: 0.5, @@ -1026,8 +1058,8 @@ export class TerritoryLayer implements Layer { return (this.contestComponentIds![tile] & CONTEST_ATTACKER_EVER_BIT) !== 0; } - private packContestTime(now: number): number { - return Math.floor(now) % CONTEST_TIME_WRAP; + private packContestTick(tick: number): number { + return Math.floor(tick) % CONTEST_TIME_WRAP; } private contestElapsed(nowPacked: number, startPacked: number): number { @@ -1078,25 +1110,6 @@ export class TerritoryLayer implements Layer { } } - private syncSmoothStateToRenderer() { - if (!this.territoryRenderer) { - return; - } - if (this.smoothActive) { - const now = this.nowMs(); - const elapsed = now - this.smoothStartMs; - const progress = Math.max( - 0, - Math.min(1, elapsed / this.smoothDurationMs), - ); - this.territoryRenderer.setSmoothProgress(progress); - this.territoryRenderer.setSmoothEnabled(true); - } else { - this.territoryRenderer.setSmoothEnabled(false); - this.territoryRenderer.setSmoothProgress(1); - } - } - private computePaletteSignature(): string { let maxSmallId = 0; for (const player of this.game.playerViews()) { @@ -1134,12 +1147,13 @@ export class TerritoryLayer implements Layer { `view: ${stats.viewWidth}x${stats.viewHeight}`, `scale: ${stats.viewScale.toFixed(2)}`, `offset: ${stats.viewOffsetX.toFixed(1)}, ${stats.viewOffsetY.toFixed(1)}`, - `smooth: ${stats.smoothEnabled ? "on" : "off"} ${stats.smoothProgress.toFixed(2)} active ${this.smoothActive ? "yes" : "no"}`, + `smooth: ${stats.smoothEnabled ? "on" : "off"} ${stats.smoothProgress.toFixed(2)} pair ${this.lastInterpolationPair}`, + `tick: ${this.tickNumberCurrent ?? "-"} prev ${this.tickNumberPrev ?? "-"}`, + `delayMs: ${this.interpolationDelayMs.toFixed(0)}`, `smoothPrereq: prevCopy ${stats.prevStateCopySupported ? "yes" : "no"}`, `jfa: ${jfaStatus} dirty ${stats.jfaDirty ? "yes" : "no"}`, `contest: ${this.contestActive ? "on" : "off"} comps ${this.contestComponents.size}`, - `contestMs: ${this.contestDurationMs}`, - `smoothMs: ${this.smoothDurationMs}`, + `contestTicks: ${this.contestDurationTicks}`, `hovered: ${stats.hoveredPlayerId}`, ]; const padding = 6; diff --git a/src/client/graphics/layers/TerritoryWebGLRenderer.ts b/src/client/graphics/layers/TerritoryWebGLRenderer.ts index 4125de09c..18c919306 100644 --- a/src/client/graphics/layers/TerritoryWebGLRenderer.ts +++ b/src/client/graphics/layers/TerritoryWebGLRenderer.ts @@ -43,14 +43,18 @@ export class TerritoryWebGLRenderer { private readonly contestSpeedsTexture: WebGLTexture | null; private readonly contestStrengthsTexture: WebGLTexture | null; private readonly prevOwnerTexture: WebGLTexture | null; + private readonly olderOwnerTexture: WebGLTexture | null; private readonly stateFramebuffer: WebGLFramebuffer | null; private readonly prevStateFramebuffer: WebGLFramebuffer | null; + private readonly olderStateFramebuffer: WebGLFramebuffer | null; private readonly jfaTextureA: WebGLTexture | null; private readonly jfaTextureB: WebGLTexture | null; private readonly jfaFramebufferA: WebGLFramebuffer | null; private readonly jfaFramebufferB: WebGLFramebuffer | null; + private readonly jfaResultOlderTexture: WebGLTexture | null; private readonly jfaResultOldTexture: WebGLTexture | null; private readonly jfaResultNewTexture: WebGLTexture | null; + private readonly jfaResultOlderFramebuffer: WebGLFramebuffer | null; private readonly jfaResultOldFramebuffer: WebGLFramebuffer | null; private readonly jfaResultNewFramebuffer: WebGLFramebuffer | null; private readonly jfaSeedProgram: WebGLProgram | null; @@ -70,6 +74,7 @@ export class TerritoryWebGLRenderer { viewScale: WebGLUniformLocation | null; viewOffset: WebGLUniformLocation | null; state: WebGLUniformLocation | null; + latestState: WebGLUniformLocation | null; palette: WebGLUniformLocation | null; relations: WebGLUniformLocation | null; patterns: WebGLUniformLocation | null; @@ -138,14 +143,16 @@ export class TerritoryWebGLRenderer { private hoveredPlayerId = -1; private animationStartTime = Date.now(); private contestNow = 0; - private contestDurationMs = 0; + private contestDurationTicks = 0; private smoothProgress = 1; private smoothEnabled = true; private jfaSupported = false; private jfaDisabledReason: string | null = null; private jfaDirty = false; + private jfaHistoryInitialized = false; private prevStateCopySupported = false; private jfaSteps: number[] = []; + private interpolationPair: "prevCurrent" | "olderPrev" = "prevCurrent"; private readonly userSettings = new UserSettings(); private readonly patternBytesCache = new Map(); @@ -191,14 +198,18 @@ export class TerritoryWebGLRenderer { this.contestSpeedsTexture = null; this.contestStrengthsTexture = null; this.prevOwnerTexture = null; + this.olderOwnerTexture = null; this.stateFramebuffer = null; this.prevStateFramebuffer = null; + this.olderStateFramebuffer = null; this.jfaTextureA = null; this.jfaTextureB = null; this.jfaFramebufferA = null; this.jfaFramebufferB = null; + this.jfaResultOlderTexture = null; this.jfaResultOldTexture = null; this.jfaResultNewTexture = null; + this.jfaResultOlderFramebuffer = null; this.jfaResultOldFramebuffer = null; this.jfaResultNewFramebuffer = null; this.jfaSeedProgram = null; @@ -211,6 +222,7 @@ export class TerritoryWebGLRenderer { viewScale: null, viewOffset: null, state: null, + latestState: null, palette: null, relations: null, patterns: null, @@ -264,14 +276,18 @@ export class TerritoryWebGLRenderer { this.contestSpeedsTexture = null; this.contestStrengthsTexture = null; this.prevOwnerTexture = null; + this.olderOwnerTexture = null; this.stateFramebuffer = null; this.prevStateFramebuffer = null; + this.olderStateFramebuffer = null; this.jfaTextureA = null; this.jfaTextureB = null; this.jfaFramebufferA = null; this.jfaFramebufferB = null; + this.jfaResultOlderTexture = null; this.jfaResultOldTexture = null; this.jfaResultNewTexture = null; + this.jfaResultOlderFramebuffer = null; this.jfaResultOldFramebuffer = null; this.jfaResultNewFramebuffer = null; this.jfaSeedProgram = null; @@ -284,6 +300,7 @@ export class TerritoryWebGLRenderer { viewScale: null, viewOffset: null, state: null, + latestState: null, palette: null, relations: null, patterns: null, @@ -357,6 +374,7 @@ export class TerritoryWebGLRenderer { viewScale: gl.getUniformLocation(this.program, "u_viewScale"), viewOffset: gl.getUniformLocation(this.program, "u_viewOffset"), state: gl.getUniformLocation(this.program, "u_state"), + latestState: gl.getUniformLocation(this.program, "u_latestState"), palette: gl.getUniformLocation(this.program, "u_palette"), relations: gl.getUniformLocation(this.program, "u_relations"), patterns: gl.getUniformLocation(this.program, "u_patterns"), @@ -372,7 +390,7 @@ export class TerritoryWebGLRenderer { contestNow: gl.getUniformLocation(this.program, "u_contestNow"), contestDuration: gl.getUniformLocation( this.program, - "u_contestDurationMs", + "u_contestDurationTicks", ), prevOwner: gl.getUniformLocation(this.program, "u_prevOwner"), jfaSeedsOld: gl.getUniformLocation(this.program, "u_jfaSeedsOld"), @@ -466,14 +484,20 @@ export class TerritoryWebGLRenderer { this.contestSpeedsTexture = gl.createTexture(); this.contestStrengthsTexture = gl.createTexture(); this.prevOwnerTexture = gl.createTexture(); + this.olderOwnerTexture = gl.createTexture(); this.stateFramebuffer = gl.createFramebuffer(); this.prevStateFramebuffer = gl.createFramebuffer(); + this.olderStateFramebuffer = gl.createFramebuffer(); this.jfaTextureA = this.jfaSupported ? gl.createTexture() : null; this.jfaTextureB = this.jfaSupported ? gl.createTexture() : null; this.jfaFramebufferA = this.jfaSupported ? gl.createFramebuffer() : null; this.jfaFramebufferB = this.jfaSupported ? gl.createFramebuffer() : null; + this.jfaResultOlderTexture = this.jfaSupported ? gl.createTexture() : null; this.jfaResultOldTexture = this.jfaSupported ? gl.createTexture() : null; this.jfaResultNewTexture = this.jfaSupported ? gl.createTexture() : null; + this.jfaResultOlderFramebuffer = this.jfaSupported + ? gl.createFramebuffer() + : null; this.jfaResultOldFramebuffer = this.jfaSupported ? gl.createFramebuffer() : null; @@ -616,11 +640,32 @@ export class TerritoryWebGLRenderer { this.state, ); + gl.activeTexture(gl.TEXTURE13); + gl.bindTexture(gl.TEXTURE_2D, this.olderOwnerTexture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.R16UI, + this.mapWidth, + this.mapHeight, + 0, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + this.state, + ); + if ( this.stateFramebuffer && this.prevStateFramebuffer && + this.olderStateFramebuffer && this.stateTexture && - this.prevOwnerTexture + this.prevOwnerTexture && + this.olderOwnerTexture ) { gl.bindFramebuffer(gl.FRAMEBUFFER, this.stateFramebuffer); gl.framebufferTexture2D( @@ -640,9 +685,19 @@ export class TerritoryWebGLRenderer { 0, ); const prevStatus = gl.checkFramebufferStatus(gl.FRAMEBUFFER); + gl.bindFramebuffer(gl.FRAMEBUFFER, this.olderStateFramebuffer); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + this.olderOwnerTexture, + 0, + ); + const olderStatus = gl.checkFramebufferStatus(gl.FRAMEBUFFER); this.prevStateCopySupported = stateStatus === gl.FRAMEBUFFER_COMPLETE && - prevStatus === gl.FRAMEBUFFER_COMPLETE; + prevStatus === gl.FRAMEBUFFER_COMPLETE && + olderStatus === gl.FRAMEBUFFER_COMPLETE; gl.bindFramebuffer(gl.FRAMEBUFFER, null); } @@ -652,8 +707,10 @@ export class TerritoryWebGLRenderer { this.jfaTextureB && this.jfaFramebufferA && this.jfaFramebufferB && + this.jfaResultOlderTexture && this.jfaResultOldTexture && this.jfaResultNewTexture && + this.jfaResultOlderFramebuffer && this.jfaResultOldFramebuffer && this.jfaResultNewFramebuffer ) { @@ -710,6 +767,24 @@ export class TerritoryWebGLRenderer { 0, ); + gl.activeTexture(gl.TEXTURE12); + gl.bindTexture(gl.TEXTURE_2D, this.jfaResultOlderTexture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RG16F, + this.mapWidth, + this.mapHeight, + 0, + gl.RG, + gl.HALF_FLOAT, + null, + ); + gl.activeTexture(gl.TEXTURE10); gl.bindTexture(gl.TEXTURE_2D, this.jfaResultOldTexture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); @@ -746,6 +821,14 @@ export class TerritoryWebGLRenderer { null, ); + gl.bindFramebuffer(gl.FRAMEBUFFER, this.jfaResultOlderFramebuffer); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + this.jfaResultOlderTexture, + 0, + ); gl.bindFramebuffer(gl.FRAMEBUFFER, this.jfaResultOldFramebuffer); gl.framebufferTexture2D( gl.FRAMEBUFFER, @@ -770,6 +853,9 @@ export class TerritoryWebGLRenderer { gl.useProgram(this.program); gl.uniform1i(this.uniforms.state, 0); + if (this.uniforms.latestState) { + gl.uniform1i(this.uniforms.latestState, 12); + } gl.uniform1i(this.uniforms.palette, 1); gl.uniform1i(this.uniforms.relations, 2); gl.uniform1i(this.uniforms.patterns, 3); @@ -905,7 +991,7 @@ export class TerritoryWebGLRenderer { gl.uniform1i(this.uniforms.contestNow, this.contestNow); } if (this.uniforms.contestDuration) { - gl.uniform1f(this.uniforms.contestDuration, this.contestDurationMs); + gl.uniform1f(this.uniforms.contestDuration, this.contestDurationTicks); } if (this.uniforms.smoothProgress) { gl.uniform1f(this.uniforms.smoothProgress, this.smoothProgress); @@ -1167,9 +1253,9 @@ export class TerritoryWebGLRenderer { this.needsContestStrengthsUpload = true; } - setContestNow(nowPacked: number, durationMs: number) { + setContestNow(nowPacked: number, durationTicks: number) { this.contestNow = nowPacked | 0; - this.contestDurationMs = Math.max(0, durationMs); + this.contestDurationTicks = Math.max(0, durationTicks); } snapshotStateForSmoothing() { @@ -1177,11 +1263,27 @@ export class TerritoryWebGLRenderer { !this.gl || !this.prevStateCopySupported || !this.stateFramebuffer || - !this.prevStateFramebuffer + !this.prevStateFramebuffer || + !this.olderStateFramebuffer ) { return; } const gl = this.gl; + + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.prevStateFramebuffer); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.olderStateFramebuffer); + gl.blitFramebuffer( + 0, + 0, + this.mapWidth, + this.mapHeight, + 0, + 0, + this.mapWidth, + this.mapHeight, + gl.COLOR_BUFFER_BIT, + gl.NEAREST, + ); gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.stateFramebuffer); gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.prevStateFramebuffer); gl.blitFramebuffer( @@ -1198,6 +1300,44 @@ export class TerritoryWebGLRenderer { ); gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null); gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null); + + if ( + this.jfaSupported && + this.jfaResultOlderFramebuffer && + this.jfaResultOldFramebuffer && + this.jfaResultNewFramebuffer + ) { + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.jfaResultOldFramebuffer); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.jfaResultOlderFramebuffer); + gl.blitFramebuffer( + 0, + 0, + this.mapWidth, + this.mapHeight, + 0, + 0, + this.mapWidth, + this.mapHeight, + gl.COLOR_BUFFER_BIT, + gl.NEAREST, + ); + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.jfaResultNewFramebuffer); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.jfaResultOldFramebuffer); + gl.blitFramebuffer( + 0, + 0, + this.mapWidth, + this.mapHeight, + 0, + 0, + this.mapWidth, + this.mapHeight, + gl.COLOR_BUFFER_BIT, + gl.NEAREST, + ); + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null); + } this.jfaDirty = true; } @@ -1214,6 +1354,10 @@ export class TerritoryWebGLRenderer { !!this.jfaResultNewTexture; } + setInterpolationPair(pair: "prevCurrent" | "olderPrev") { + this.interpolationPair = pair; + } + markAllDirty() { this.needsFullUpload = true; this.dirtyRows.clear(); @@ -1270,7 +1414,7 @@ export class TerritoryWebGLRenderer { uploadContestStrengthsSpan, ); - if (this.jfaSupported && this.smoothEnabled) { + if (this.jfaSupported) { this.updateJfa(); } @@ -1278,9 +1422,23 @@ export class TerritoryWebGLRenderer { gl.viewport(0, 0, this.viewWidth, this.viewHeight); gl.useProgram(this.program); gl.bindVertexArray(this.vao); - if (this.stateTexture) { + + const canUseOlderPair = + this.interpolationPair === "olderPrev" && + !!this.prevOwnerTexture && + !!this.olderOwnerTexture && + !!this.jfaResultOldTexture && + !!this.jfaResultOlderTexture; + const renderPair = canUseOlderPair ? "olderPrev" : "prevCurrent"; + + const toStateTexture = + renderPair === "olderPrev" ? this.prevOwnerTexture : this.stateTexture; + const fromStateTexture = + renderPair === "olderPrev" ? this.olderOwnerTexture : this.prevOwnerTexture; + + if (toStateTexture) { gl.activeTexture(gl.TEXTURE0); - gl.bindTexture(gl.TEXTURE_2D, this.stateTexture); + gl.bindTexture(gl.TEXTURE_2D, toStateTexture); } if (this.paletteTexture) { gl.activeTexture(gl.TEXTURE1); @@ -1306,17 +1464,27 @@ export class TerritoryWebGLRenderer { gl.activeTexture(gl.TEXTURE6); gl.bindTexture(gl.TEXTURE_2D, this.contestTimesTexture); } - if (this.prevOwnerTexture) { + if (fromStateTexture) { gl.activeTexture(gl.TEXTURE7); - gl.bindTexture(gl.TEXTURE_2D, this.prevOwnerTexture); + gl.bindTexture(gl.TEXTURE_2D, fromStateTexture); } - if (this.jfaResultOldTexture) { + + const seedsOld = + renderPair === "olderPrev" ? this.jfaResultOlderTexture : this.jfaResultOldTexture; + const seedsNew = + renderPair === "olderPrev" ? this.jfaResultOldTexture : this.jfaResultNewTexture; + if (seedsOld) { gl.activeTexture(gl.TEXTURE8); - gl.bindTexture(gl.TEXTURE_2D, this.jfaResultOldTexture); + gl.bindTexture(gl.TEXTURE_2D, seedsOld); } - if (this.jfaResultNewTexture) { + if (seedsNew) { gl.activeTexture(gl.TEXTURE9); - gl.bindTexture(gl.TEXTURE_2D, this.jfaResultNewTexture); + gl.bindTexture(gl.TEXTURE_2D, seedsNew); + } + + if (this.stateTexture) { + gl.activeTexture(gl.TEXTURE12); + gl.bindTexture(gl.TEXTURE_2D, this.stateTexture); } if (this.contestSpeedsTexture) { gl.activeTexture(gl.TEXTURE10); @@ -1377,7 +1545,7 @@ export class TerritoryWebGLRenderer { gl.uniform1i(this.uniforms.contestNow, this.contestNow); } if (this.uniforms.contestDuration) { - gl.uniform1f(this.uniforms.contestDuration, this.contestDurationMs); + gl.uniform1f(this.uniforms.contestDuration, this.contestDurationTicks); } if (this.uniforms.smoothProgress) { gl.uniform1f(this.uniforms.smoothProgress, this.smoothProgress); @@ -1408,7 +1576,7 @@ export class TerritoryWebGLRenderer { jfaDisabledReason: this.jfaDisabledReason, jfaDirty: this.jfaDirty, prevStateCopySupported: this.prevStateCopySupported, - contestDurationMs: this.contestDurationMs, + contestDurationTicks: this.contestDurationTicks, contestNow: this.contestNow, hoveredPlayerId: this.hoveredPlayerId, }; @@ -1662,11 +1830,8 @@ export class TerritoryWebGLRenderer { !this.jfaFramebufferB || !this.jfaTextureA || !this.jfaTextureB || - !this.prevOwnerTexture || !this.stateTexture || - !this.jfaResultOldFramebuffer || !this.jfaResultNewFramebuffer || - !this.jfaResultOldTexture || !this.jfaResultNewTexture || !this.jfaVao ) { @@ -1749,11 +1914,47 @@ export class TerritoryWebGLRenderer { ); }; - runJfa(this.prevOwnerTexture, this.jfaResultOldFramebuffer); runJfa(this.stateTexture, this.jfaResultNewFramebuffer); this.jfaDirty = false; + if ( + !this.jfaHistoryInitialized && + this.jfaResultOlderFramebuffer && + this.jfaResultOldFramebuffer + ) { + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.jfaResultNewFramebuffer); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.jfaResultOldFramebuffer); + gl.blitFramebuffer( + 0, + 0, + this.mapWidth, + this.mapHeight, + 0, + 0, + this.mapWidth, + this.mapHeight, + gl.COLOR_BUFFER_BIT, + gl.NEAREST, + ); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.jfaResultOlderFramebuffer); + gl.blitFramebuffer( + 0, + 0, + this.mapWidth, + this.mapHeight, + 0, + 0, + this.mapWidth, + this.mapHeight, + gl.COLOR_BUFFER_BIT, + gl.NEAREST, + ); + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null); + this.jfaHistoryInitialized = true; + } + gl.bindFramebuffer(gl.FRAMEBUFFER, null); if (prevBlend) { gl.enable(gl.BLEND); @@ -2142,6 +2343,7 @@ export class TerritoryWebGLRenderer { precision highp usampler2D; uniform usampler2D u_state; + uniform usampler2D u_latestState; uniform sampler2D u_palette; uniform usampler2D u_relations; uniform usampler2D u_patterns; @@ -2152,7 +2354,7 @@ export class TerritoryWebGLRenderer { uniform usampler2D u_contestStrengths; uniform bool u_jfaAvailable; uniform int u_contestNow; - uniform float u_contestDurationMs; + uniform float u_contestDurationTicks; uniform usampler2D u_prevOwner; uniform sampler2D u_jfaSeedsOld; uniform sampler2D u_jfaSeedsNew; @@ -2377,6 +2579,8 @@ export class TerritoryWebGLRenderer { 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); bool smoothActive = u_smoothEnabled && u_smoothProgress < 1.0 && @@ -2398,7 +2602,7 @@ export class TerritoryWebGLRenderer { uint elapsed = nowTime >= lastTime ? (nowTime - lastTime) : (CONTEST_WRAP - lastTime + nowTime); - contested = float(elapsed) < u_contestDurationMs; + contested = float(elapsed) < u_contestDurationTicks; } bool isBorder = false; @@ -2511,8 +2715,15 @@ export class TerritoryWebGLRenderer { vec3 contestedFillColor = fillColor; float contestedFillAlpha = fillAlpha; - if (contested && owner != 0u) { - vec3 defenderBase = ownerBase; + bool useContestedFill = false; + if (contested && latestOwner != 0u) { + useContestedFill = true; + vec3 latestOwnerBase = texelFetch( + u_palette, + ivec2(int(latestOwner) * 2, 0), + 0 + ).rgb; + vec3 defenderBase = latestOwnerBase; if (defender != 0u) { vec4 defenderColor = texelFetch( u_palette, @@ -2523,12 +2734,12 @@ export class TerritoryWebGLRenderer { } float strength = contestStrength(contestId); float noise = blueNoise(texCoord); - contestedFillColor = noise < strength ? ownerBase : defenderBase; + contestedFillColor = noise < strength ? latestOwnerBase : defenderBase; contestedFillAlpha = u_alpha; } - vec3 color = contested ? contestedFillColor : fillColor; - float a = contested ? contestedFillAlpha : fillAlpha; + vec3 color = useContestedFill ? contestedFillColor : fillColor; + float a = useContestedFill ? contestedFillAlpha : fillAlpha; if (isBorder && owner != 0u) { color = borderColor; @@ -2661,7 +2872,27 @@ export class TerritoryWebGLRenderer { } } - if (contested && owner != 0u && u_jfaAvailable) { + bool pendingOwnerChange = latestOwner != owner; + if (pendingOwnerChange && !useContestedFill && !u_alternativeView) { + vec3 hintColor = vec3(1.0); + if (latestOwner != 0u) { + hintColor = texelFetch( + u_palette, + ivec2(int(latestOwner) * 2, 0), + 0 + ).rgb; + } + const float HINT_ALPHA_RATIO = 0.12; + float hintAlpha = u_alpha * HINT_ALPHA_RATIO; + if (a < hintAlpha) { + a = hintAlpha; + color = hintColor; + } else { + color = mix(color, hintColor, 0.08); + } + } + + if (useContestedFill && u_jfaAvailable) { vec2 seedOld = jfaSeedOldAtTex(texCoord); vec2 seedNew = jfaSeedNewAtTex(texCoord); if (seedOld.x >= 0.0 && seedNew.x >= 0.0) {