From abe62e27daf97ba57ae9a2ec7b87664342d6c23f Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Fri, 5 Dec 2025 13:52:28 +0100 Subject: [PATCH] Add tick interpolation support to TerritoryLayer and WebGLRenderer - Introduced tick management in TerritoryLayer with lastTickSeen, tickParity, and tickDurationMs properties. - Updated tick method to handle tile updates based on the current tick and manage WebGL rendering timing. - Enhanced TerritoryRendererStrategy interface with new methods for updating tile arrival and setting tick timing. - Implemented arrival phase and tick parity handling in TerritoryWebGLRenderer, including texture updates for changed tiles. - Improved shader logic to manage visibility of tiles based on tick progress and arrival phase. --- src/client/graphics/layers/TerritoryLayer.ts | 25 + .../graphics/layers/TerritoryRenderers.ts | 28 + .../graphics/layers/TerritoryWebGLRenderer.ts | 544 +++++++++++++----- 3 files changed, 463 insertions(+), 134 deletions(-) diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index c63e25534..e4e1dfaf2 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -70,6 +70,9 @@ export class TerritoryLayer implements Layer { private lastMyPlayerSmallId: number | null = null; private useWebGL: boolean; private webglSupported = true; + private lastTickSeen = 0; + private tickParity = 0; + private tickDurationMs = 100; constructor( private game: GameView, @@ -97,6 +100,28 @@ export class TerritoryLayer implements Layer { tick() { const tickProfile = FrameProfiler.start(); + + const currentTick = this.game.ticks(); + if (currentTick !== this.lastTickSeen) { + this.lastTickSeen = currentTick; + this.tickParity ^= 1; + const tickStartMs = performance.now(); + const changedTiles = this.game.recentlyUpdatedTiles(); + if (this.territoryRenderer?.isWebGL()) { + const webglRenderer = this.territoryRenderer as WebglTerritoryRenderer; + webglRenderer.updateArrivalForChangedTiles( + changedTiles, + this.tickParity, + ); + webglRenderer.setTickTiming( + currentTick, + tickStartMs, + this.tickDurationMs, + this.tickParity, + ); + } + } + if (this.game.inSpawnPhase()) { this.spawnHighlight(); } diff --git a/src/client/graphics/layers/TerritoryRenderers.ts b/src/client/graphics/layers/TerritoryRenderers.ts index 3d453d539..4afd36adc 100644 --- a/src/client/graphics/layers/TerritoryRenderers.ts +++ b/src/client/graphics/layers/TerritoryRenderers.ts @@ -23,6 +23,13 @@ export interface TerritoryRendererStrategy { setHoverHighlightOptions(options: HoverHighlightOptions): void; refreshPalette(): void; clearTile(tile: TileRef): void; + updateArrivalForChangedTiles(tiles: TileRef[], tickParity: number): void; + setTickTiming( + tick: number, + startMs: number, + durationMs: number, + parity: number, + ): void; } export class CanvasTerritoryRenderer implements TerritoryRendererStrategy { @@ -235,6 +242,14 @@ export class CanvasTerritoryRenderer implements TerritoryRendererStrategy { this.alternativeImageData.data[offset + 3] = 0; }); } + + updateArrivalForChangedTiles(): void { + // No tick intrapolation for canvas renderer. + } + + setTickTiming(): void { + // No tick intrapolation for canvas renderer. + } } export class WebglTerritoryRenderer implements TerritoryRendererStrategy { @@ -298,4 +313,17 @@ export class WebglTerritoryRenderer implements TerritoryRendererStrategy { clearTile(): void { // No-op for WebGL; canvas alpha clearing is not used. } + + updateArrivalForChangedTiles(tiles: TileRef[], tickParity: number): void { + this.renderer.updateArrivalForChangedTiles(this.game, tiles, tickParity); + } + + setTickTiming( + tick: number, + startMs: number, + durationMs: number, + parity: number, + ): void { + this.renderer.setTickTiming(tick, startMs, durationMs, parity); + } } diff --git a/src/client/graphics/layers/TerritoryWebGLRenderer.ts b/src/client/graphics/layers/TerritoryWebGLRenderer.ts index 034ed0cad..8d09c23f3 100644 --- a/src/client/graphics/layers/TerritoryWebGLRenderer.ts +++ b/src/client/graphics/layers/TerritoryWebGLRenderer.ts @@ -61,6 +61,11 @@ export class TerritoryWebGLRenderer { borderDefendedFriendlyDark: WebGLUniformLocation | null; borderDefendedEmbargoLight: WebGLUniformLocation | null; borderDefendedEmbargoDark: WebGLUniformLocation | null; + // Tick intrapolation uniforms + prevState: WebGLUniformLocation | null; + arrivalPhase: WebGLUniformLocation | null; + tickProgress: WebGLUniformLocation | null; + tickParity: WebGLUniformLocation | null; }; private readonly state: Uint16Array; @@ -75,6 +80,23 @@ export class TerritoryWebGLRenderer { private hoveredPlayerId = -1; private animationStartTime = Date.now(); + // Tick intrapolation buffers (client-side only, WebGL path). + private readonly prevState: Uint16Array; + private readonly arrivalPhase: Uint8Array; + private readonly changeParity: Uint8Array; + + // Packed texel buffer backing the arrivalPhase texture (RG8: phase, parity). + private readonly arrivalPhaseTexData: Uint8Array; + + // Corresponding textures. + private readonly arrivalPhaseTexture: WebGLTexture | null = null; + + // Tick timing for shader. + private lastTickId = 0; + private lastTickStartMs = 0; + private tickDurationMs = 100; + private tickParity = 0; + private constructor( private readonly game: GameView, private readonly theme: Theme, @@ -85,6 +107,12 @@ export class TerritoryWebGLRenderer { this.canvas.height = game.height(); this.state = new Uint16Array(sharedState); + // Allocate intrapolation buffers. + const numTiles = this.canvas.width * this.canvas.height; + this.prevState = new Uint16Array(numTiles); + this.arrivalPhase = new Uint8Array(numTiles); + this.changeParity = new Uint8Array(numTiles); + this.arrivalPhaseTexData = new Uint8Array(numTiles * 2); this.gl = this.canvas.getContext("webgl2", { premultipliedAlpha: true, @@ -127,6 +155,10 @@ export class TerritoryWebGLRenderer { borderDefendedFriendlyDark: null, borderDefendedEmbargoLight: null, borderDefendedEmbargoDark: null, + prevState: null, + arrivalPhase: null, + tickProgress: null, + tickParity: null, }; return; } @@ -167,6 +199,10 @@ export class TerritoryWebGLRenderer { borderDefendedFriendlyDark: null, borderDefendedEmbargoLight: null, borderDefendedEmbargoDark: null, + prevState: null, + arrivalPhase: null, + tickProgress: null, + tickParity: null, }; return; } @@ -226,6 +262,10 @@ export class TerritoryWebGLRenderer { this.program, "u_borderDefendedEmbargoDark", ), + prevState: null, + arrivalPhase: gl.getUniformLocation(this.program, "u_arrivalPhase"), + tickProgress: gl.getUniformLocation(this.program, "u_tickProgress"), + tickParity: gl.getUniformLocation(this.program, "u_tickParity"), }; // Vertex data: two triangles covering the full map (pixel-perfect). @@ -259,6 +299,14 @@ export class TerritoryWebGLRenderer { this.paletteTexture = gl.createTexture(); this.relationTexture = gl.createTexture(); + // Initialize intrapolation buffers from current game state. + this.initPrevStateFromGame(); + + // Create texture for arrivalPhase. + const arrivalTex = gl.createTexture(); + this.arrivalPhaseTexture = arrivalTex; + + // State texture (current map state from SAB). gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.stateTexture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); @@ -278,12 +326,37 @@ export class TerritoryWebGLRenderer { this.state, ); + // Arrival phase texture (use RG8, R=phase, G=parity flag). + if (this.arrivalPhaseTexture) { + gl.activeTexture(gl.TEXTURE4); + gl.bindTexture(gl.TEXTURE_2D, this.arrivalPhaseTexture); + 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.RG8, + this.canvas.width, + this.canvas.height, + 0, + gl.RG, + gl.UNSIGNED_BYTE, + this.arrivalPhaseTexData, + ); + } + this.uploadPalette(); gl.useProgram(this.program); gl.uniform1i(this.uniforms.state, 0); gl.uniform1i(this.uniforms.palette, 1); gl.uniform1i(this.uniforms.relations, 2); + if (this.uniforms.arrivalPhase) { + gl.uniform1i(this.uniforms.arrivalPhase, 4); + } if (this.uniforms.resolution) { gl.uniform2f( @@ -438,6 +511,128 @@ export class TerritoryWebGLRenderer { } } + /** + * Initialize prevState buffer from the current GameView map state. + * Only called once during construction. + */ + private initPrevStateFromGame() { + const numTiles = this.canvas.width * this.canvas.height; + for (let tile = 0; tile < numTiles; tile++) { + const ownerId = this.game.ownerID(tile as TileRef); + const hasFallout = this.game.hasFallout(tile as TileRef); + const isDefended = this.game.isDefended(tile as TileRef); + let state = ownerId & 0x0fff; + if (isDefended) { + state |= 1 << 12; + } + if (hasFallout) { + state |= 1 << 13; + } + this.prevState[tile] = state; + this.arrivalPhase[tile] = 0xff; + this.changeParity[tile] = 0; + const idx = tile * 2; + this.arrivalPhaseTexData[idx] = this.arrivalPhase[tile]; + this.arrivalPhaseTexData[idx + 1] = this.changeParity[tile]; + } + } + + /** + * Update arrival phase and parity for tiles that changed this tick. + * prevState is used as "old" state for animated tiles and is not + * overwritten here for those tiles. + */ + updateArrivalForChangedTiles( + game: GameView, + tiles: TileRef[], + tickParity: number, + ) { + const maxRadius = 8; + const width = this.canvas.width; + const height = this.canvas.height; + + for (const tile of tiles) { + const prevStateWord = this.prevState[tile]; + const prevOwner = prevStateWord & 0x0fff; + const newStateWord = this.state[tile]; + const newOwner = newStateWord & 0x0fff; + + // Only animate owner changes for now. + if (prevOwner === newOwner) { + // Keep prevState in sync so future owner-change detection is correct. + this.prevState[tile] = newStateWord; + continue; + } + + const x0 = game.x(tile); + const y0 = game.y(tile); + let minDistSq = Number.POSITIVE_INFINITY; + + for (let dy = -maxRadius; dy <= maxRadius; dy++) { + const y = y0 + dy; + if (y < 0 || y >= height) continue; + for (let dx = -maxRadius; dx <= maxRadius; dx++) { + const x = x0 + dx; + if (x < 0 || x >= width) continue; + const distSq = dx * dx + dy * dy; + if (distSq > maxRadius * maxRadius) continue; + const t2 = game.ref(x, y); + const prevOwnerHere = this.prevState[t2] & 0x0fff; + if (prevOwnerHere === newOwner && distSq < minDistSq) { + minDistSq = distSq; + if (minDistSq <= 1) break; + } + } + if (minDistSq <= 1) break; + } + + let phaseByte = 0xff; + if (minDistSq !== Number.POSITIVE_INFINITY) { + const dist = Math.sqrt(minDistSq); + const clipped = Math.min(dist, maxRadius); + const phase = maxRadius > 0 ? clipped / maxRadius : 1.0; + phaseByte = Math.max(0, Math.min(255, Math.round(phase * 255))); + } + + this.arrivalPhase[tile] = phaseByte; + // Store parity as 0 or 255 so the sampler2D normalized channel is easy to threshold. + this.changeParity[tile] = tickParity ? 255 : 0; + + const idx = tile * 2; + this.arrivalPhaseTexData[idx] = this.arrivalPhase[tile]; + this.arrivalPhaseTexData[idx + 1] = this.changeParity[tile]; + + // Update the previous-state snapshot to reflect the newly applied state, + // so subsequent ticks treat this owner as the "old" owner. + this.prevState[tile] = newStateWord; + } + + // Mark all potentially affected rows dirty so the textures will be updated. + for (const tile of tiles) { + const x = tile % this.canvas.width; + const y = Math.floor(tile / this.canvas.width); + const span = this.dirtyRows.get(y); + if (span === undefined) { + this.dirtyRows.set(y, { minX: x, maxX: x }); + } else { + span.minX = Math.min(span.minX, x); + span.maxX = Math.max(span.maxX, x); + } + } + } + + setTickTiming( + tick: number, + startMs: number, + durationMs: number, + parity: number, + ) { + this.lastTickId = tick; + this.lastTickStartMs = startMs; + this.tickDurationMs = durationMs; + this.tickParity = parity; + } + markTile(tile: TileRef) { if (this.needsFullUpload) { return; @@ -501,6 +696,18 @@ export class TerritoryWebGLRenderer { if (this.uniforms.hoverPulseSpeed) { gl.uniform1f(this.uniforms.hoverPulseSpeed, this.hoverPulseSpeed); } + if (this.uniforms.tickProgress) { + const now = performance.now(); + const dt = Math.max(0, now - this.lastTickStartMs); + const progress = + this.tickDurationMs > 0 + ? Math.max(0, Math.min(1, dt / this.tickDurationMs)) + : 1; + gl.uniform1f(this.uniforms.tickProgress, progress); + } + if (this.uniforms.tickParity) { + gl.uniform1i(this.uniforms.tickParity, this.tickParity); + } if (this.uniforms.time) { const currentTime = (Date.now() - this.animationStartTime) / 1000.0; gl.uniform1f(this.uniforms.time, currentTime); @@ -543,10 +750,13 @@ export class TerritoryWebGLRenderer { this.dirtyRows.clear(); rowsUploaded = this.canvas.height; bytesUploaded = this.canvas.width * this.canvas.height * bytesPerPixel; + // Full upload: also refresh arrivalPhase texture. + this.uploadArrivalPhaseTexture(true); return { rows: rowsUploaded, bytes: bytesUploaded }; } if (this.dirtyRows.size === 0) { + // No state changes; still keep prev/arrival textures as-is. return { rows: 0, bytes: 0 }; } @@ -568,10 +778,58 @@ export class TerritoryWebGLRenderer { rowsUploaded++; bytesUploaded += width * bytesPerPixel; } + + // Apply same row set to arrivalPhase. + this.uploadArrivalPhaseTexture(false); + this.dirtyRows.clear(); return { rows: rowsUploaded, bytes: bytesUploaded }; } + private uploadArrivalPhaseTexture(full: boolean) { + if (!this.gl || !this.arrivalPhaseTexture) return; + const gl = this.gl; + gl.activeTexture(gl.TEXTURE4); + gl.bindTexture(gl.TEXTURE_2D, this.arrivalPhaseTexture); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + + if (full) { + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RG8, + this.canvas.width, + this.canvas.height, + 0, + gl.RG, + gl.UNSIGNED_BYTE, + this.arrivalPhaseTexData, + ); + return; + } + + if (this.dirtyRows.size === 0) return; + + for (const [y, span] of this.dirtyRows) { + const width = span.maxX - span.minX + 1; + const startPixel = y * this.canvas.width + span.minX; + const startByte = startPixel * 2; + const endByte = (startPixel + width) * 2; + const rowSlice = this.arrivalPhaseTexData.subarray(startByte, endByte); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + span.minX, + y, + width, + 1, + gl.RG, + gl.UNSIGNED_BYTE, + rowSlice, + ); + } + } + /** * Formats upload metrics into a human-readable string for logging/debugging. * Used for performance monitoring of WebGL texture uploads, bucketing values @@ -713,36 +971,39 @@ export class TerritoryWebGLRenderer { `; const fragmentShaderSource = `#version 300 es - precision mediump float; - precision highp usampler2D; - - uniform usampler2D u_state; - uniform sampler2D u_palette; - uniform usampler2D u_relations; - uniform int u_viewerId; - uniform vec2 u_resolution; - uniform vec4 u_fallout; - uniform vec4 u_altSelf; - uniform vec4 u_altAlly; - uniform vec4 u_altNeutral; - uniform vec4 u_altEnemy; - uniform float u_alpha; - uniform vec4 u_borderNeutral; - uniform vec4 u_borderFriendly; - uniform vec4 u_borderEmbargo; - uniform vec4 u_borderDefendedNeutralLight; - uniform vec4 u_borderDefendedNeutralDark; - uniform vec4 u_borderDefendedFriendlyLight; - uniform vec4 u_borderDefendedFriendlyDark; - uniform vec4 u_borderDefendedEmbargoLight; - uniform vec4 u_borderDefendedEmbargoDark; - uniform bool u_alternativeView; - uniform float u_hoveredPlayerId; - uniform vec3 u_hoverHighlightColor; - uniform float u_hoverHighlightStrength; - uniform float u_hoverPulseStrength; - uniform float u_hoverPulseSpeed; - uniform float u_time; + precision mediump float; + precision highp usampler2D; + + uniform usampler2D u_state; + uniform sampler2D u_palette; + uniform usampler2D u_relations; + uniform int u_viewerId; + uniform vec2 u_resolution; + uniform vec4 u_fallout; + uniform vec4 u_altSelf; + uniform vec4 u_altAlly; + uniform vec4 u_altNeutral; + uniform vec4 u_altEnemy; + uniform float u_alpha; + uniform vec4 u_borderNeutral; + uniform vec4 u_borderFriendly; + uniform vec4 u_borderEmbargo; + uniform vec4 u_borderDefendedNeutralLight; + uniform vec4 u_borderDefendedNeutralDark; + uniform vec4 u_borderDefendedFriendlyLight; + uniform vec4 u_borderDefendedFriendlyDark; + uniform vec4 u_borderDefendedEmbargoLight; + uniform vec4 u_borderDefendedEmbargoDark; + uniform bool u_alternativeView; + uniform float u_hoveredPlayerId; + uniform vec3 u_hoverHighlightColor; + uniform float u_hoverHighlightStrength; + uniform float u_hoverPulseStrength; + uniform float u_hoverPulseSpeed; + uniform float u_time; + uniform sampler2D u_arrivalPhase; + uniform float u_tickProgress; + uniform int u_tickParity; out vec4 outColor; @@ -774,72 +1035,123 @@ export class TerritoryWebGLRenderer { return (code & 4u) != 0u; } - void main() { - ivec2 fragCoord = ivec2(gl_FragCoord.xy); - // gl_FragCoord origin is bottom-left; flip Y to match top-left oriented buffers. - ivec2 texCoord = ivec2(fragCoord.x, int(u_resolution.y) - 1 - fragCoord.y); + void main() { + ivec2 fragCoord = ivec2(gl_FragCoord.xy); + // gl_FragCoord origin is bottom-left; flip Y to match top-left oriented buffers. + ivec2 texCoord = ivec2(fragCoord.x, int(u_resolution.y) - 1 - fragCoord.y); - uint state = texelFetch(u_state, texCoord, 0).r; - uint owner = state & 0xFFFu; - bool hasFallout = (state & 0x2000u) != 0u; // bit 13 - bool isDefended = (state & 0x1000u) != 0u; // bit 12 + uint state = texelFetch(u_state, texCoord, 0).r; + uint owner = state & 0xFFFu; + bool hasFallout = (state & 0x2000u) != 0u; // bit 13 + bool isDefended = (state & 0x1000u) != 0u; // bit 12 - if (owner == 0u) { - if (hasFallout) { - vec3 color = u_fallout.rgb; - float a = u_alpha; + vec4 arrival = texture(u_arrivalPhase, (vec2(texCoord) + 0.5) / u_resolution); + float arrivalPhase = arrival.r; + int changeParity = arrival.g > 0.5 ? 1 : 0; + + if (owner == 0u) { + if (hasFallout) { + vec3 color = u_fallout.rgb; + float a = u_alpha; + outColor = vec4(color * a, a); + } else { + outColor = vec4(0.0); + } + return; + } + + // Border detection via neighbor comparison and relation checks + bool isBorder = false; + bool hasFriendlyRelation = false; + bool hasEmbargoRelation = false; + uint nOwner = ownerAtTex(texCoord + ivec2(1, 0)); + isBorder = isBorder || (nOwner != owner); + if (nOwner != owner && nOwner != 0u) { + uint rel = relationCode(owner, nOwner); + hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel); + hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel); + } + nOwner = ownerAtTex(texCoord + ivec2(-1, 0)); + isBorder = isBorder || (nOwner != owner); + if (nOwner != owner && nOwner != 0u) { + uint rel = relationCode(owner, nOwner); + hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel); + hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel); + } + nOwner = ownerAtTex(texCoord + ivec2(0, 1)); + isBorder = isBorder || (nOwner != owner); + if (nOwner != owner && nOwner != 0u) { + uint rel = relationCode(owner, nOwner); + hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel); + hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel); + } + nOwner = ownerAtTex(texCoord + ivec2(0, -1)); + isBorder = isBorder || (nOwner != owner); + if (nOwner != owner && nOwner != 0u) { + uint rel = relationCode(owner, nOwner); + hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel); + hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel); + } + + if (u_alternativeView) { + uint relationAlt = relationCode(owner, uint(u_viewerId)); + vec4 altColor = u_altNeutral; + if (isSelf(relationAlt)) { + altColor = u_altSelf; + } else if (isFriendly(relationAlt)) { + altColor = u_altAlly; + } else if (isEmbargo(relationAlt)) { + altColor = u_altEnemy; + } + float a = isBorder ? 1.0 : 0.0; + vec3 color = altColor.rgb; + if (u_hoveredPlayerId >= 0.0 && abs(float(owner) - u_hoveredPlayerId) < 0.5) { + float pulse = u_hoverPulseStrength > 0.0 + ? (1.0 - u_hoverPulseStrength) + + u_hoverPulseStrength * (0.5 + 0.5 * sin(u_time * u_hoverPulseSpeed)) + : 1.0; + color = mix(color, u_hoverHighlightColor, u_hoverHighlightStrength * pulse); + } outColor = vec4(color * a, a); - } else { - outColor = vec4(0.0); + return; } - return; - } - // Border detection via neighbor comparison and relation checks - bool isBorder = false; - bool hasFriendlyRelation = false; - bool hasEmbargoRelation = false; - uint nOwner = ownerAtTex(texCoord + ivec2(1, 0)); - isBorder = isBorder || (nOwner != owner); - if (nOwner != owner && nOwner != 0u) { - uint rel = relationCode(owner, nOwner); - hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel); - hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel); - } - nOwner = ownerAtTex(texCoord + ivec2(-1, 0)); - isBorder = isBorder || (nOwner != owner); - if (nOwner != owner && nOwner != 0u) { - uint rel = relationCode(owner, nOwner); - hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel); - hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel); - } - nOwner = ownerAtTex(texCoord + ivec2(0, 1)); - isBorder = isBorder || (nOwner != owner); - if (nOwner != owner && nOwner != 0u) { - uint rel = relationCode(owner, nOwner); - hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel); - hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel); - } - nOwner = ownerAtTex(texCoord + ivec2(0, -1)); - isBorder = isBorder || (nOwner != owner); - if (nOwner != owner && nOwner != 0u) { - uint rel = relationCode(owner, nOwner); - hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel); - hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel); - } + // Current owner color + vec4 base = texelFetch(u_palette, ivec2(int(owner) * 2, 0), 0); // territory color + vec4 baseBorder = texelFetch(u_palette, ivec2(int(owner) * 2 + 1, 0), 0); // base border color + vec3 color = base.rgb; + float a = u_alpha; - if (u_alternativeView) { - uint relationAlt = relationCode(owner, uint(u_viewerId)); - vec4 altColor = u_altNeutral; - if (isSelf(relationAlt)) { - altColor = u_altSelf; - } else if (isFriendly(relationAlt)) { - altColor = u_altAlly; - } else if (isEmbargo(relationAlt)) { - altColor = u_altEnemy; + if (isBorder) { + // Start with base border color and apply relation tint + vec3 borderColor = baseBorder.rgb; + + // Apply relation-based tinting (same logic as PlayerView.borderColor) + const float BORDER_TINT_RATIO = 0.35; + const vec3 FRIENDLY_TINT_TARGET = vec3(0.0, 1.0, 0.0); // green + const vec3 EMBARGO_TINT_TARGET = vec3(1.0, 0.0, 0.0); // red + + if (hasFriendlyRelation) { // friendly + borderColor = borderColor * (1.0 - BORDER_TINT_RATIO) + + FRIENDLY_TINT_TARGET * BORDER_TINT_RATIO; + } + if (hasEmbargoRelation) { // embargo + borderColor = borderColor * (1.0 - BORDER_TINT_RATIO) + + EMBARGO_TINT_TARGET * BORDER_TINT_RATIO; + } + + // Apply defended checkerboard pattern + if (isDefended) { + bool isLightTile = ((texCoord.x % 2) == (texCoord.y % 2)); + const float LIGHT_FACTOR = 1.2; + const float DARK_FACTOR = 0.8; + borderColor *= isLightTile ? LIGHT_FACTOR : DARK_FACTOR; + } + + color = borderColor; + a = baseBorder.a; // Already in 0-1 range from RGBA8 texture } - float a = isBorder ? 1.0 : 0.0; - vec3 color = altColor.rgb; + if (u_hoveredPlayerId >= 0.0 && abs(float(owner) - u_hoveredPlayerId) < 0.5) { float pulse = u_hoverPulseStrength > 0.0 ? (1.0 - u_hoverPulseStrength) + @@ -847,56 +1159,20 @@ export class TerritoryWebGLRenderer { : 1.0; color = mix(color, u_hoverHighlightColor, u_hoverHighlightStrength * pulse); } - outColor = vec4(color * a, a); - return; - } - vec4 base = texelFetch(u_palette, ivec2(int(owner) * 2, 0), 0); // territory color - vec4 baseBorder = texelFetch(u_palette, ivec2(int(owner) * 2 + 1, 0), 0); // base border color - vec3 color = base.rgb; - float a = u_alpha; + vec4 currColor = vec4(color * a, a); - if (isBorder) { - // Start with base border color and apply relation tint - vec3 borderColor = baseBorder.rgb; - - // Apply relation-based tinting (same logic as PlayerView.borderColor) - const float BORDER_TINT_RATIO = 0.35; - const vec3 FRIENDLY_TINT_TARGET = vec3(0.0, 1.0, 0.0); // green - const vec3 EMBARGO_TINT_TARGET = vec3(1.0, 0.0, 0.0); // red - - if (hasFriendlyRelation) { // friendly - borderColor = borderColor * (1.0 - BORDER_TINT_RATIO) + - FRIENDLY_TINT_TARGET * BORDER_TINT_RATIO; - } - if (hasEmbargoRelation) { // embargo - borderColor = borderColor * (1.0 - BORDER_TINT_RATIO) + - EMBARGO_TINT_TARGET * BORDER_TINT_RATIO; + // Simple arrival-based gating: tiles that changed this tick + // remain invisible until their local arrivalPhase is reached. + bool tileChangedThisTick = (changeParity == u_tickParity) && (arrivalPhase < 1.0); + if (tileChangedThisTick && u_tickProgress < arrivalPhase) { + outColor = vec4(0.0); + return; } - // Apply defended checkerboard pattern - if (isDefended) { - bool isLightTile = ((texCoord.x % 2) == (texCoord.y % 2)); - const float LIGHT_FACTOR = 1.2; - const float DARK_FACTOR = 0.8; - borderColor *= isLightTile ? LIGHT_FACTOR : DARK_FACTOR; - } - - color = borderColor; - a = baseBorder.a; // Already in 0-1 range from RGBA8 texture + outColor = currColor; } - - if (u_hoveredPlayerId >= 0.0 && abs(float(owner) - u_hoveredPlayerId) < 0.5) { - float pulse = u_hoverPulseStrength > 0.0 - ? (1.0 - u_hoverPulseStrength) + - u_hoverPulseStrength * (0.5 + 0.5 * sin(u_time * u_hoverPulseSpeed)) - : 1.0; - color = mix(color, u_hoverHighlightColor, u_hoverHighlightStrength * pulse); - } - - outColor = vec4(color * a, a); - } - `; + `; const vertexShader = this.compileShader( gl,