import { base64url } from "jose"; import { DefaultPattern } from "../../../core/CosmeticSchemas"; import { Theme } from "../../../core/configuration/Config"; import { TileRef } from "../../../core/game/GameMap"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { UserSettings } from "../../../core/game/UserSettings"; import { FrameProfiler } from "../FrameProfiler"; type DirtySpan = { minX: number; maxX: number }; export interface TerritoryWebGLCreateResult { renderer: TerritoryWebGLRenderer | null; reason?: string; } export interface HoverHighlightOptions { color?: { r: number; g: number; b: number }; strength?: number; pulseStrength?: number; pulseSpeed?: number; } const PATTERN_STRIDE_BYTES = 1052; // WebGL2 territory renderer that shades tiles from packed tile state // (Uint16Array) using palette, relation, and pattern textures. export class TerritoryWebGLRenderer { public readonly canvas: HTMLCanvasElement; private contestEnabled = false; private contestPatternMode: 0 | 1 | 2 = 0; // 0=blueNoise(strength), 1=checkerboard(50/50), 2=bayer4x4(strength) private debugDisableStaticBorders = false; private debugDisableAllBorders = false; private seedSamplingMode: 0 | 1 | 2 = 1; // 0=none(single texel), 1=2x2, 2=3x3 private debugStripeFixedColors = false; // Use fixed debug colors for moving stripe private motionMode: 0 | 1 | 2 | 3 = 0; // 0=euclidean, 1=axisSnap, 2=manhattan, 3=chebyshev private readonly gl: WebGL2RenderingContext | null; private readonly program: WebGLProgram | null; private readonly vao: WebGLVertexArrayObject | null; private readonly vertexBuffer: WebGLBuffer | null; private readonly jfaVao: WebGLVertexArrayObject | null; private readonly jfaVertexBuffer: WebGLBuffer | null; private readonly stateTexture: WebGLTexture | null; private readonly terrainTexture: WebGLTexture | null; private readonly paletteTexture: WebGLTexture | null; private readonly relationTexture: WebGLTexture | null; private readonly patternTexture: WebGLTexture | null; private readonly contestOwnersTexture: WebGLTexture | null; private readonly contestIdsTexture: WebGLTexture | null; private readonly contestTimesTexture: 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; private readonly jfaProgram: WebGLProgram | null; private readonly changeMaskProgram: WebGLProgram | null; private readonly changeMaskTextureOlder: WebGLTexture | null; private readonly changeMaskTextureOld: WebGLTexture | null; private readonly changeMaskTextureNew: WebGLTexture | null; private readonly changeMaskFramebufferOlder: WebGLFramebuffer | null; private readonly changeMaskFramebufferOld: WebGLFramebuffer | null; private readonly changeMaskFramebufferNew: WebGLFramebuffer | null; private readonly jfaSeedUniforms: { resolution: WebGLUniformLocation | null; owner: WebGLUniformLocation | null; }; private readonly jfaUniforms: { resolution: WebGLUniformLocation | null; step: WebGLUniformLocation | null; seeds: WebGLUniformLocation | null; }; private readonly changeMaskUniforms: { resolution: WebGLUniformLocation | null; oldTexture: WebGLUniformLocation | null; newTexture: WebGLUniformLocation | null; }; private readonly uniforms: { mapResolution: WebGLUniformLocation | null; viewResolution: WebGLUniformLocation | null; viewScale: WebGLUniformLocation | null; viewOffset: WebGLUniformLocation | null; state: WebGLUniformLocation | null; terrain: WebGLUniformLocation | null; latestState: WebGLUniformLocation | null; palette: WebGLUniformLocation | null; relations: WebGLUniformLocation | null; patterns: WebGLUniformLocation | null; contestEnabled: WebGLUniformLocation | null; contestPatternMode: WebGLUniformLocation | null; debugDisableStaticBorders: WebGLUniformLocation | null; debugDisableAllBorders: WebGLUniformLocation | null; seedSamplingMode: WebGLUniformLocation | null; debugStripeFixedColors: WebGLUniformLocation | null; motionMode: WebGLUniformLocation | null; contestOwners: WebGLUniformLocation | null; contestIds: WebGLUniformLocation | null; contestTimes: WebGLUniformLocation | null; contestStrengths: WebGLUniformLocation | null; jfaAvailable: WebGLUniformLocation | null; contestNow: WebGLUniformLocation | null; contestDuration: WebGLUniformLocation | null; prevOwner: WebGLUniformLocation | null; jfaSeedsOld: WebGLUniformLocation | null; jfaSeedsNew: WebGLUniformLocation | null; smoothProgress: WebGLUniformLocation | null; changeMask: WebGLUniformLocation | null; smoothEnabled: WebGLUniformLocation | null; patternStride: WebGLUniformLocation | null; patternRows: WebGLUniformLocation | null; fallout: WebGLUniformLocation | null; altSelf: WebGLUniformLocation | null; altAlly: WebGLUniformLocation | null; altNeutral: WebGLUniformLocation | null; altEnemy: WebGLUniformLocation | null; alpha: WebGLUniformLocation | null; alternativeView: WebGLUniformLocation | null; hoveredPlayerId: WebGLUniformLocation | null; hoverHighlightStrength: WebGLUniformLocation | null; hoverHighlightColor: WebGLUniformLocation | null; hoverPulseStrength: WebGLUniformLocation | null; hoverPulseSpeed: WebGLUniformLocation | null; time: WebGLUniformLocation | null; viewerId: WebGLUniformLocation | null; darkMode: WebGLUniformLocation | null; }; private readonly mapWidth: number; private readonly mapHeight: number; private viewWidth: number; private viewHeight: number; private viewScale = 1; private viewOffsetX = 0; private viewOffsetY = 0; private readonly state: Uint16Array; private contestOwnersState: Uint16Array; private contestIdsState: Uint16Array; private contestTimesState: Uint16Array; private contestStrengthsState: Uint16Array; private readonly dirtyRows: Map = new Map(); private readonly contestDirtyRows: Map = new Map(); private needsFullUpload = true; private needsContestFullUpload = true; private needsContestTimesUpload = true; private needsContestStrengthsUpload = true; private alternativeView = false; private paletteWidth = 0; // Defaults are overridden by setHoverHighlightOptions() from TerritoryLayer. private hoverHighlightStrength = 0.3; // Defaults are overridden by setHoverHighlightOptions() from TerritoryLayer. private hoverHighlightColor: [number, number, number] = [1, 1, 1]; // Defaults are overridden by setHoverHighlightOptions() from TerritoryLayer. private hoverPulseStrength = 0.25; // Defaults are overridden by setHoverHighlightOptions() from TerritoryLayer. private hoverPulseSpeed = Math.PI * 2; private hoveredPlayerId = -1; private hoverStartTime = 0; private static readonly HOVER_DURATION_MS = 5000; private animationStartTime = Date.now(); private contestNow = 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 changeMaskDirty = false; private changeMaskHistoryInitialized = false; private prevStateCopySupported = false; private jfaSteps: number[] = []; private interpolationPair: "prevCurrent" | "olderPrev" = "prevCurrent"; private readonly userSettings = new UserSettings(); private readonly patternBytesCache = new Map(); private constructor( private readonly game: GameView, private readonly theme: Theme, state: Uint16Array, ) { this.canvas = document.createElement("canvas"); this.mapWidth = game.width(); this.mapHeight = game.height(); this.viewWidth = this.mapWidth; this.viewHeight = this.mapHeight; this.canvas.width = this.viewWidth; this.canvas.height = this.viewHeight; this.state = state; this.contestOwnersState = new Uint16Array(state.length * 2); this.contestIdsState = new Uint16Array(state.length); this.contestTimesState = new Uint16Array(1); this.contestStrengthsState = new Uint16Array(1); this.gl = this.canvas.getContext("webgl2", { premultipliedAlpha: true, antialias: false, preserveDrawingBuffer: true, }); if (!this.gl) { this.program = null; this.vao = null; this.vertexBuffer = null; this.jfaVao = null; this.jfaVertexBuffer = null; this.stateTexture = null; this.terrainTexture = null; this.paletteTexture = null; this.relationTexture = null; this.patternTexture = null; this.contestOwnersTexture = null; this.contestIdsTexture = null; this.contestTimesTexture = 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; this.jfaProgram = null; this.changeMaskProgram = null; this.changeMaskTextureOlder = null; this.changeMaskTextureOld = null; this.changeMaskTextureNew = null; this.changeMaskFramebufferOlder = null; this.changeMaskFramebufferOld = null; this.changeMaskFramebufferNew = null; this.jfaSeedUniforms = { resolution: null, owner: null }; this.jfaUniforms = { resolution: null, step: null, seeds: null }; this.changeMaskUniforms = { resolution: null, oldTexture: null, newTexture: null, }; this.uniforms = { mapResolution: null, viewResolution: null, viewScale: null, viewOffset: null, state: null, terrain: null, latestState: null, palette: null, relations: null, patterns: null, contestEnabled: null, contestPatternMode: null, debugDisableStaticBorders: null, debugDisableAllBorders: null, seedSamplingMode: null, debugStripeFixedColors: null, motionMode: null, contestOwners: null, contestIds: null, contestTimes: null, contestStrengths: null, jfaAvailable: null, contestNow: null, contestDuration: null, prevOwner: null, jfaSeedsOld: null, jfaSeedsNew: null, smoothProgress: null, changeMask: null, smoothEnabled: null, patternStride: null, patternRows: null, fallout: null, altSelf: null, altAlly: null, altNeutral: null, altEnemy: null, alpha: null, alternativeView: null, hoveredPlayerId: null, hoverHighlightStrength: null, hoverHighlightColor: null, hoverPulseStrength: null, hoverPulseSpeed: null, time: null, viewerId: null, darkMode: null, }; return; } const gl = this.gl; this.program = this.createProgram(gl); if (!this.program) { this.vao = null; this.vertexBuffer = null; this.jfaVao = null; this.jfaVertexBuffer = null; this.stateTexture = null; this.terrainTexture = null; this.paletteTexture = null; this.relationTexture = null; this.patternTexture = null; this.contestOwnersTexture = null; this.contestIdsTexture = null; this.contestTimesTexture = 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; this.jfaProgram = null; this.changeMaskProgram = null; this.changeMaskTextureOlder = null; this.changeMaskTextureOld = null; this.changeMaskTextureNew = null; this.changeMaskFramebufferOlder = null; this.changeMaskFramebufferOld = null; this.changeMaskFramebufferNew = null; this.jfaSeedUniforms = { resolution: null, owner: null }; this.jfaUniforms = { resolution: null, step: null, seeds: null }; this.changeMaskUniforms = { resolution: null, oldTexture: null, newTexture: null, }; this.uniforms = { mapResolution: null, viewResolution: null, viewScale: null, viewOffset: null, state: null, terrain: null, latestState: null, palette: null, relations: null, patterns: null, contestEnabled: null, contestPatternMode: null, debugDisableStaticBorders: null, debugDisableAllBorders: null, seedSamplingMode: null, debugStripeFixedColors: null, motionMode: null, contestOwners: null, contestIds: null, contestTimes: null, contestStrengths: null, jfaAvailable: null, contestNow: null, contestDuration: null, prevOwner: null, jfaSeedsOld: null, jfaSeedsNew: null, smoothProgress: null, changeMask: null, smoothEnabled: null, patternStride: null, patternRows: null, fallout: null, altSelf: null, altAlly: null, altNeutral: null, altEnemy: null, alpha: null, alternativeView: null, hoveredPlayerId: null, hoverHighlightStrength: null, hoverHighlightColor: null, hoverPulseStrength: null, hoverPulseSpeed: null, time: null, viewerId: null, darkMode: null, }; return; } this.jfaSupported = !!gl.getExtension("EXT_color_buffer_float"); if (!this.jfaSupported) { this.jfaDisabledReason = "EXT_color_buffer_float unavailable"; } this.jfaSeedProgram = this.jfaSupported ? this.createJfaSeedProgram(gl) : null; this.jfaProgram = this.jfaSupported ? this.createJfaProgram(gl) : null; this.changeMaskProgram = this.jfaSupported ? this.createChangeMaskProgram(gl) : null; if (!this.jfaSeedProgram || !this.jfaProgram) { this.jfaSupported = false; this.jfaDisabledReason ??= "JFA shaders unavailable"; } this.jfaSeedUniforms = this.jfaSeedProgram ? { resolution: gl.getUniformLocation( this.jfaSeedProgram, "u_resolution", ), owner: gl.getUniformLocation(this.jfaSeedProgram, "u_ownerTexture"), } : { resolution: null, owner: null }; this.jfaUniforms = this.jfaProgram ? { resolution: gl.getUniformLocation(this.jfaProgram, "u_resolution"), step: gl.getUniformLocation(this.jfaProgram, "u_step"), seeds: gl.getUniformLocation(this.jfaProgram, "u_seeds"), } : { resolution: null, step: null, seeds: null }; this.changeMaskUniforms = this.changeMaskProgram ? { resolution: gl.getUniformLocation( this.changeMaskProgram, "u_resolution", ), oldTexture: gl.getUniformLocation( this.changeMaskProgram, "u_oldTexture", ), newTexture: gl.getUniformLocation( this.changeMaskProgram, "u_newTexture", ), } : { resolution: null, oldTexture: null, newTexture: null }; this.uniforms = { mapResolution: gl.getUniformLocation(this.program, "u_mapResolution"), viewResolution: gl.getUniformLocation(this.program, "u_viewResolution"), viewScale: gl.getUniformLocation(this.program, "u_viewScale"), viewOffset: gl.getUniformLocation(this.program, "u_viewOffset"), state: gl.getUniformLocation(this.program, "u_state"), terrain: gl.getUniformLocation(this.program, "u_terrain"), 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"), contestEnabled: gl.getUniformLocation(this.program, "u_contestEnabled"), contestPatternMode: gl.getUniformLocation( this.program, "u_contestPatternMode", ), debugDisableStaticBorders: gl.getUniformLocation( this.program, "u_debugDisableStaticBorders", ), debugDisableAllBorders: gl.getUniformLocation( this.program, "u_debugDisableAllBorders", ), seedSamplingMode: gl.getUniformLocation( this.program, "u_seedSamplingMode", ), debugStripeFixedColors: gl.getUniformLocation( this.program, "u_debugStripeFixedColors", ), motionMode: gl.getUniformLocation(this.program, "u_motionMode"), contestOwners: gl.getUniformLocation(this.program, "u_contestOwners"), contestIds: gl.getUniformLocation(this.program, "u_contestIds"), contestTimes: gl.getUniformLocation(this.program, "u_contestTimes"), contestStrengths: gl.getUniformLocation( this.program, "u_contestStrengths", ), jfaAvailable: gl.getUniformLocation(this.program, "u_jfaAvailable"), contestNow: gl.getUniformLocation(this.program, "u_contestNow"), contestDuration: gl.getUniformLocation( this.program, "u_contestDurationTicks", ), prevOwner: gl.getUniformLocation(this.program, "u_prevOwner"), jfaSeedsOld: gl.getUniformLocation(this.program, "u_jfaSeedsOld"), jfaSeedsNew: gl.getUniformLocation(this.program, "u_jfaSeedsNew"), smoothProgress: gl.getUniformLocation(this.program, "u_smoothProgress"), changeMask: gl.getUniformLocation(this.program, "u_changeMask"), smoothEnabled: gl.getUniformLocation(this.program, "u_smoothEnabled"), patternStride: gl.getUniformLocation(this.program, "u_patternStride"), patternRows: gl.getUniformLocation(this.program, "u_patternRows"), fallout: gl.getUniformLocation(this.program, "u_fallout"), altSelf: gl.getUniformLocation(this.program, "u_altSelf"), altAlly: gl.getUniformLocation(this.program, "u_altAlly"), altNeutral: gl.getUniformLocation(this.program, "u_altNeutral"), altEnemy: gl.getUniformLocation(this.program, "u_altEnemy"), alpha: gl.getUniformLocation(this.program, "u_alpha"), alternativeView: gl.getUniformLocation(this.program, "u_alternativeView"), hoveredPlayerId: gl.getUniformLocation(this.program, "u_hoveredPlayerId"), hoverHighlightStrength: gl.getUniformLocation( this.program, "u_hoverHighlightStrength", ), hoverHighlightColor: gl.getUniformLocation( this.program, "u_hoverHighlightColor", ), hoverPulseStrength: gl.getUniformLocation( this.program, "u_hoverPulseStrength", ), hoverPulseSpeed: gl.getUniformLocation(this.program, "u_hoverPulseSpeed"), time: gl.getUniformLocation(this.program, "u_time"), viewerId: gl.getUniformLocation(this.program, "u_viewerId"), darkMode: gl.getUniformLocation(this.program, "u_darkMode"), }; // Vertex data: two triangles covering the full view (pixel-perfect). const vertices = new Float32Array([ 0, 0, this.viewWidth, 0, 0, this.viewHeight, 0, this.viewHeight, this.viewWidth, 0, this.viewWidth, this.viewHeight, ]); this.vao = gl.createVertexArray(); this.vertexBuffer = gl.createBuffer(); gl.bindVertexArray(this.vao); gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); const posLoc = gl.getAttribLocation(this.program, "a_position"); gl.enableVertexAttribArray(posLoc); gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 2 * 4, 0); gl.bindVertexArray(null); const mapVertices = new Float32Array([ 0, 0, this.mapWidth, 0, 0, this.mapHeight, 0, this.mapHeight, this.mapWidth, 0, this.mapWidth, this.mapHeight, ]); this.jfaVao = gl.createVertexArray(); this.jfaVertexBuffer = gl.createBuffer(); gl.bindVertexArray(this.jfaVao); gl.bindBuffer(gl.ARRAY_BUFFER, this.jfaVertexBuffer); gl.bufferData(gl.ARRAY_BUFFER, mapVertices, gl.STATIC_DRAW); gl.enableVertexAttribArray(posLoc); gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 2 * 4, 0); gl.bindVertexArray(null); this.stateTexture = gl.createTexture(); this.terrainTexture = gl.createTexture(); this.paletteTexture = gl.createTexture(); this.relationTexture = gl.createTexture(); this.patternTexture = gl.createTexture(); this.contestOwnersTexture = gl.createTexture(); this.contestIdsTexture = gl.createTexture(); this.contestTimesTexture = 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; this.jfaResultNewFramebuffer = this.jfaSupported ? gl.createFramebuffer() : null; this.changeMaskTextureOlder = this.jfaSupported ? gl.createTexture() : null; this.changeMaskTextureOld = this.jfaSupported ? gl.createTexture() : null; this.changeMaskTextureNew = this.jfaSupported ? gl.createTexture() : null; this.changeMaskFramebufferOlder = this.jfaSupported ? gl.createFramebuffer() : null; this.changeMaskFramebufferOld = this.jfaSupported ? gl.createFramebuffer() : null; this.changeMaskFramebufferNew = this.jfaSupported ? gl.createFramebuffer() : null; gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.stateTexture); 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, ); // Terrain texture (immutable, only uploaded once) gl.activeTexture(gl.TEXTURE14); gl.bindTexture(gl.TEXTURE_2D, this.terrainTexture); 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.R8UI, this.mapWidth, this.mapHeight, 0, gl.RED_INTEGER, gl.UNSIGNED_BYTE, game.terrainDataView(), ); this.uploadPalette(); gl.activeTexture(gl.TEXTURE4); gl.bindTexture(gl.TEXTURE_2D, this.contestOwnersTexture); 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.RG16UI, this.mapWidth, this.mapHeight, 0, gl.RG_INTEGER, gl.UNSIGNED_SHORT, this.contestOwnersState, ); gl.activeTexture(gl.TEXTURE5); gl.bindTexture(gl.TEXTURE_2D, this.contestIdsTexture); 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.contestIdsState, ); gl.activeTexture(gl.TEXTURE6); gl.bindTexture(gl.TEXTURE_2D, this.contestTimesTexture); 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.contestTimesState.length, 1, 0, gl.RED_INTEGER, gl.UNSIGNED_SHORT, this.contestTimesState, ); gl.activeTexture(gl.TEXTURE11); gl.bindTexture(gl.TEXTURE_2D, this.contestStrengthsTexture); 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.contestStrengthsState.length, 1, 0, gl.RED_INTEGER, gl.UNSIGNED_SHORT, this.contestStrengthsState, ); gl.activeTexture(gl.TEXTURE7); gl.bindTexture(gl.TEXTURE_2D, this.prevOwnerTexture); 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, ); 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.olderOwnerTexture ) { gl.bindFramebuffer(gl.FRAMEBUFFER, this.stateFramebuffer); gl.framebufferTexture2D( gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.stateTexture, 0, ); const stateStatus = gl.checkFramebufferStatus(gl.FRAMEBUFFER); gl.bindFramebuffer(gl.FRAMEBUFFER, this.prevStateFramebuffer); gl.framebufferTexture2D( gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.prevOwnerTexture, 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 && olderStatus === gl.FRAMEBUFFER_COMPLETE; gl.bindFramebuffer(gl.FRAMEBUFFER, null); } if ( this.jfaSupported && this.jfaTextureA && this.jfaTextureB && this.jfaFramebufferA && this.jfaFramebufferB && this.jfaResultOlderTexture && this.jfaResultOldTexture && this.jfaResultNewTexture && this.jfaResultOlderFramebuffer && this.jfaResultOldFramebuffer && this.jfaResultNewFramebuffer ) { gl.activeTexture(gl.TEXTURE9); gl.bindTexture(gl.TEXTURE_2D, this.jfaTextureA); 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.jfaTextureB); 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.bindFramebuffer(gl.FRAMEBUFFER, this.jfaFramebufferA); gl.framebufferTexture2D( gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.jfaTextureA, 0, ); gl.bindFramebuffer(gl.FRAMEBUFFER, this.jfaFramebufferB); gl.framebufferTexture2D( gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.jfaTextureB, 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); 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.TEXTURE11); gl.bindTexture(gl.TEXTURE_2D, this.jfaResultNewTexture); 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.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, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.jfaResultOldTexture, 0, ); gl.bindFramebuffer(gl.FRAMEBUFFER, this.jfaResultNewFramebuffer); gl.framebufferTexture2D( gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.jfaResultNewTexture, 0, ); gl.bindFramebuffer(gl.FRAMEBUFFER, null); this.jfaSteps = this.buildJfaSteps(this.mapWidth, this.mapHeight); this.jfaDirty = true; } if ( this.jfaSupported && this.changeMaskTextureOlder && this.changeMaskTextureOld && this.changeMaskTextureNew && this.changeMaskFramebufferOlder && this.changeMaskFramebufferOld && this.changeMaskFramebufferNew ) { const initMaskTex = (tex: WebGLTexture) => { gl.bindTexture(gl.TEXTURE_2D, tex); 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.R8UI, this.mapWidth, this.mapHeight, 0, gl.RED_INTEGER, gl.UNSIGNED_BYTE, null, ); }; gl.activeTexture(gl.TEXTURE13); initMaskTex(this.changeMaskTextureOlder); initMaskTex(this.changeMaskTextureOld); initMaskTex(this.changeMaskTextureNew); gl.bindFramebuffer(gl.FRAMEBUFFER, this.changeMaskFramebufferOlder); gl.framebufferTexture2D( gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.changeMaskTextureOlder, 0, ); gl.clearBufferuiv(gl.COLOR, 0, new Uint32Array([0, 0, 0, 0])); gl.bindFramebuffer(gl.FRAMEBUFFER, this.changeMaskFramebufferOld); gl.framebufferTexture2D( gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.changeMaskTextureOld, 0, ); gl.clearBufferuiv(gl.COLOR, 0, new Uint32Array([0, 0, 0, 0])); gl.bindFramebuffer(gl.FRAMEBUFFER, this.changeMaskFramebufferNew); gl.framebufferTexture2D( gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.changeMaskTextureNew, 0, ); gl.clearBufferuiv(gl.COLOR, 0, new Uint32Array([0, 0, 0, 0])); gl.bindFramebuffer(gl.FRAMEBUFFER, null); this.changeMaskDirty = true; } gl.useProgram(this.program); gl.uniform1i(this.uniforms.state, 0); if (this.uniforms.terrain) { gl.uniform1i(this.uniforms.terrain, 14); } 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); gl.uniform1i(this.uniforms.contestOwners, 4); gl.uniform1i(this.uniforms.contestIds, 5); gl.uniform1i(this.uniforms.contestTimes, 6); gl.uniform1i(this.uniforms.contestStrengths, 11); gl.uniform1i(this.uniforms.prevOwner, 7); gl.uniform1i(this.uniforms.jfaSeedsOld, 8); gl.uniform1i(this.uniforms.jfaSeedsNew, 9); if (this.uniforms.changeMask) { gl.uniform1i(this.uniforms.changeMask, 13); } if (this.uniforms.mapResolution) { gl.uniform2f(this.uniforms.mapResolution, this.mapWidth, this.mapHeight); } if (this.uniforms.viewResolution) { gl.uniform2f( this.uniforms.viewResolution, this.viewWidth, this.viewHeight, ); } if (this.uniforms.viewScale) { gl.uniform1f(this.uniforms.viewScale, this.viewScale); } if (this.uniforms.viewOffset) { gl.uniform2f( this.uniforms.viewOffset, this.viewOffsetX, this.viewOffsetY, ); } if (this.uniforms.alpha) { gl.uniform1f(this.uniforms.alpha, 150 / 255); } if (this.uniforms.fallout) { const f = this.theme.falloutColor().rgba; gl.uniform4f( this.uniforms.fallout, f.r / 255, f.g / 255, f.b / 255, f.a ?? 1, ); } if (this.uniforms.altSelf) { const c = this.theme.selfColor().rgba; gl.uniform4f( this.uniforms.altSelf, c.r / 255, c.g / 255, c.b / 255, c.a ?? 1, ); } if (this.uniforms.altAlly) { const c = this.theme.allyColor().rgba; gl.uniform4f( this.uniforms.altAlly, c.r / 255, c.g / 255, c.b / 255, c.a ?? 1, ); } if (this.uniforms.altNeutral) { const c = this.theme.neutralColor().rgba; gl.uniform4f( this.uniforms.altNeutral, c.r / 255, c.g / 255, c.b / 255, c.a ?? 1, ); } if (this.uniforms.altEnemy) { const c = this.theme.enemyColor().rgba; gl.uniform4f( this.uniforms.altEnemy, c.r / 255, c.g / 255, c.b / 255, c.a ?? 1, ); } if (this.uniforms.viewerId) { const viewerId = this.game.myPlayer()?.smallID() ?? 0; gl.uniform1i(this.uniforms.viewerId, viewerId); } if (this.uniforms.viewResolution) { gl.uniform2f( this.uniforms.viewResolution, this.viewWidth, this.viewHeight, ); } if (this.uniforms.viewScale) { gl.uniform1f(this.uniforms.viewScale, this.viewScale); } if (this.uniforms.viewOffset) { gl.uniform2f( this.uniforms.viewOffset, this.viewOffsetX, this.viewOffsetY, ); } if (this.uniforms.alternativeView) { gl.uniform1i(this.uniforms.alternativeView, 0); } if (this.uniforms.hoveredPlayerId) { gl.uniform1f(this.uniforms.hoveredPlayerId, -1); } if (this.uniforms.hoverHighlightStrength) { gl.uniform1f( this.uniforms.hoverHighlightStrength, this.hoverHighlightStrength, ); } if (this.uniforms.hoverHighlightColor) { const [r, g, b] = this.hoverHighlightColor; gl.uniform3f(this.uniforms.hoverHighlightColor, r, g, b); } if (this.uniforms.hoverPulseStrength) { gl.uniform1f(this.uniforms.hoverPulseStrength, this.hoverPulseStrength); } if (this.uniforms.hoverPulseSpeed) { gl.uniform1f(this.uniforms.hoverPulseSpeed, this.hoverPulseSpeed); } if (this.uniforms.jfaAvailable) { gl.uniform1i(this.uniforms.jfaAvailable, this.jfaSupported ? 1 : 0); } if (this.uniforms.contestNow) { gl.uniform1i(this.uniforms.contestNow, this.contestNow); } if (this.uniforms.contestDuration) { gl.uniform1f(this.uniforms.contestDuration, this.contestDurationTicks); } if (this.uniforms.smoothProgress) { gl.uniform1f(this.uniforms.smoothProgress, this.smoothProgress); } if (this.uniforms.smoothEnabled) { gl.uniform1i(this.uniforms.smoothEnabled, this.smoothEnabled ? 1 : 0); } if ( this.jfaSupported && this.jfaResultOldTexture && this.jfaResultNewTexture ) { gl.activeTexture(gl.TEXTURE8); gl.bindTexture(gl.TEXTURE_2D, this.jfaResultOldTexture); gl.activeTexture(gl.TEXTURE9); gl.bindTexture(gl.TEXTURE_2D, this.jfaResultNewTexture); } gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); gl.viewport(0, 0, this.viewWidth, this.viewHeight); } static create(game: GameView, theme: Theme): TerritoryWebGLCreateResult { const state = game.tileStateView(); const expected = game.width() * game.height(); if (state.length !== expected) { return { renderer: null, reason: "Tile state buffer size mismatch; WebGL renderer disabled.", }; } const renderer = new TerritoryWebGLRenderer(game, theme, state); if (!renderer.isValid()) { return { renderer: null, reason: "WebGL2 not available; WebGL renderer disabled.", }; } return { renderer }; } isValid(): boolean { return !!this.gl && !!this.program && !!this.vao; } dispose(): void { if (this.gl) { this.gl.getExtension("WEBGL_lose_context")?.loseContext(); } this.canvas.remove(); } setAlternativeView(enabled: boolean) { this.alternativeView = enabled; } setViewSize(width: number, height: number) { const nextWidth = Math.max(1, Math.floor(width)); const nextHeight = Math.max(1, Math.floor(height)); if (nextWidth === this.viewWidth && nextHeight === this.viewHeight) { return; } this.viewWidth = nextWidth; this.viewHeight = nextHeight; this.canvas.width = nextWidth; this.canvas.height = nextHeight; if (!this.gl || !this.vertexBuffer) { return; } const gl = this.gl; const vertices = new Float32Array([ 0, 0, this.viewWidth, 0, 0, this.viewHeight, 0, this.viewHeight, this.viewWidth, 0, this.viewWidth, this.viewHeight, ]); gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); if (this.program) { gl.useProgram(this.program); if (this.uniforms.viewResolution) { gl.uniform2f( this.uniforms.viewResolution, this.viewWidth, this.viewHeight, ); } } } setViewTransform(scale: number, offsetX: number, offsetY: number) { this.viewScale = scale; this.viewOffsetX = offsetX; this.viewOffsetY = offsetY; } setHoveredPlayerId(playerSmallId: number | null) { const encoded = playerSmallId ?? -1; if (encoded !== this.hoveredPlayerId) { this.hoveredPlayerId = encoded; this.hoverStartTime = encoded >= 0 ? Date.now() : 0; } } setHoverHighlightOptions(options: HoverHighlightOptions) { if (options.strength !== undefined) { this.hoverHighlightStrength = Math.max(0, Math.min(1, options.strength)); } if (options.color) { this.hoverHighlightColor = [ options.color.r / 255, options.color.g / 255, options.color.b / 255, ]; } if (options.pulseStrength !== undefined) { this.hoverPulseStrength = Math.max(0, Math.min(1, options.pulseStrength)); } if (options.pulseSpeed !== undefined) { this.hoverPulseSpeed = Math.max(0, options.pulseSpeed); } } setContestEnabled(enabled: boolean) { if (this.contestEnabled === enabled) { return; } this.contestEnabled = enabled; if (this.contestEnabled) { this.needsContestFullUpload = true; this.needsContestTimesUpload = true; this.needsContestStrengthsUpload = true; } else { this.contestDirtyRows.clear(); } } setContestPatternMode(mode: "blueNoise" | "checkerboard" | "bayer4x4") { if (mode === "checkerboard") this.contestPatternMode = 1; else if (mode === "bayer4x4") this.contestPatternMode = 2; else this.contestPatternMode = 0; } setDebugDisableStaticBorders(disabled: boolean) { this.debugDisableStaticBorders = disabled; } setDebugDisableAllBorders(disabled: boolean) { this.debugDisableAllBorders = disabled; } setSeedSamplingMode(mode: "none" | "2x2" | "3x3") { this.seedSamplingMode = mode === "none" ? 0 : mode === "2x2" ? 1 : 2; } setDebugStripeFixedColors(enabled: boolean) { this.debugStripeFixedColors = enabled; } setMotionMode(mode: "euclidean" | "axisSnap" | "manhattan" | "chebyshev") { if (mode === "axisSnap") this.motionMode = 1; else if (mode === "manhattan") this.motionMode = 2; else if (mode === "chebyshev") this.motionMode = 3; else this.motionMode = 0; } markTile(tile: TileRef) { if (this.needsFullUpload) { return; } const x = tile % this.mapWidth; const y = Math.floor(tile / this.mapWidth); 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); } } setContestTile( tile: TileRef, defenderOwner: number, attackerOwner: number, componentId: number, attackerEver: boolean, ) { if (!this.contestEnabled) { return; } const offset = tile * 2; const defenderValue = defenderOwner & 0xffff; const attackerValue = attackerOwner & 0xffff; const idValue = (componentId & 0x7fff) | (attackerEver ? 0x8000 : 0); if ( this.contestOwnersState[offset] === defenderValue && this.contestOwnersState[offset + 1] === attackerValue && this.contestIdsState[tile] === idValue ) { return; } this.contestOwnersState[offset] = defenderValue; this.contestOwnersState[offset + 1] = attackerValue; this.contestIdsState[tile] = idValue; if (this.needsContestFullUpload) { return; } const x = tile % this.mapWidth; const y = Math.floor(tile / this.mapWidth); const span = this.contestDirtyRows.get(y); if (span === undefined) { this.contestDirtyRows.set(y, { minX: x, maxX: x }); } else { span.minX = Math.min(span.minX, x); span.maxX = Math.max(span.maxX, x); } } clearContestTile(tile: TileRef) { this.setContestTile(tile, 0, 0, 0, false); } setContestTime(componentId: number, nowPacked: number) { if (!this.contestEnabled) { return; } if (componentId <= 0) { return; } this.ensureContestTimeCapacity(componentId); const packed = nowPacked & 0xffff; if (this.contestTimesState[componentId] === packed) { return; } this.contestTimesState[componentId] = packed; this.needsContestTimesUpload = true; } ensureContestTimeCapacity(componentId: number) { if (componentId < this.contestTimesState.length) { return; } let nextLength = Math.max(1, this.contestTimesState.length); while (nextLength <= componentId) { nextLength *= 2; } const nextState = new Uint16Array(nextLength); nextState.set(this.contestTimesState); this.contestTimesState = nextState; this.needsContestTimesUpload = true; } setContestStrength(componentId: number, strength: number) { if (!this.contestEnabled) { return; } if (componentId <= 0) { return; } this.ensureContestStrengthCapacity(componentId); const clamped = Math.max(0, Math.min(1, strength)); const packed = Math.round(clamped * 65535) & 0xffff; if (this.contestStrengthsState[componentId] === packed) { return; } this.contestStrengthsState[componentId] = packed; this.needsContestStrengthsUpload = true; } ensureContestStrengthCapacity(componentId: number) { if (componentId < this.contestStrengthsState.length) { return; } let nextLength = Math.max(1, this.contestStrengthsState.length); while (nextLength <= componentId) { nextLength *= 2; } const nextState = new Uint16Array(nextLength); nextState.set(this.contestStrengthsState); this.contestStrengthsState = nextState; this.needsContestStrengthsUpload = true; } setContestNow(nowPacked: number, durationTicks: number) { if (!this.contestEnabled) { return; } this.contestNow = nowPacked | 0; this.contestDurationTicks = Math.max(0, durationTicks); } snapshotStateForSmoothing() { if ( !this.gl || !this.prevStateCopySupported || !this.stateFramebuffer || !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( 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); 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); } if ( this.jfaSupported && this.changeMaskFramebufferOlder && this.changeMaskFramebufferOld && this.changeMaskFramebufferNew ) { gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.changeMaskFramebufferOld); gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.changeMaskFramebufferOlder); 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.changeMaskFramebufferNew); gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.changeMaskFramebufferOld); 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; this.changeMaskDirty = true; } setSmoothProgress(progress: number) { this.smoothProgress = Math.max(0, Math.min(1, progress)); } setSmoothEnabled(enabled: boolean) { this.smoothEnabled = enabled && this.jfaSupported && this.prevStateCopySupported && !!this.changeMaskProgram && !!this.changeMaskTextureOld && !!this.changeMaskTextureNew && !!this.jfaResultOldTexture && !!this.jfaResultNewTexture; } setInterpolationPair(pair: "prevCurrent" | "olderPrev") { this.interpolationPair = pair; } markAllDirty() { this.needsFullUpload = true; this.dirtyRows.clear(); this.needsContestFullUpload = true; this.needsContestTimesUpload = true; this.needsContestStrengthsUpload = true; this.contestDirtyRows.clear(); this.jfaDirty = true; this.changeMaskDirty = true; } refreshPalette() { if (!this.gl || !this.paletteTexture || !this.relationTexture) { return; } this.uploadPalette(); } render() { if (!this.gl || !this.program || !this.vao) { return; } const gl = this.gl; const uploadStateSpan = FrameProfiler.start(); this.uploadStateTexture(); FrameProfiler.end("TerritoryWebGLRenderer:uploadState", uploadStateSpan); if (this.contestEnabled) { const uploadContestSpan = FrameProfiler.start(); this.uploadContestTexture(); FrameProfiler.end( "TerritoryWebGLRenderer:uploadContests", uploadContestSpan, ); const uploadContestTimesSpan = FrameProfiler.start(); this.uploadContestTimesTexture(); FrameProfiler.end( "TerritoryWebGLRenderer:uploadContestTimes", uploadContestTimesSpan, ); const uploadContestStrengthsSpan = FrameProfiler.start(); this.uploadContestStrengthsTexture(); FrameProfiler.end( "TerritoryWebGLRenderer:uploadContestStrengths", uploadContestStrengthsSpan, ); } if (this.jfaSupported) { this.updateChangeMask(); this.updateJfa(); } const renderSpan = FrameProfiler.start(); gl.viewport(0, 0, this.viewWidth, this.viewHeight); gl.useProgram(this.program); gl.bindVertexArray(this.vao); 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, toStateTexture); } if (this.paletteTexture) { gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture); } if (this.relationTexture) { gl.activeTexture(gl.TEXTURE2); gl.bindTexture(gl.TEXTURE_2D, this.relationTexture); } if (this.patternTexture) { gl.activeTexture(gl.TEXTURE3); gl.bindTexture(gl.TEXTURE_2D, this.patternTexture); } if (this.contestOwnersTexture) { gl.activeTexture(gl.TEXTURE4); gl.bindTexture(gl.TEXTURE_2D, this.contestOwnersTexture); } if (this.contestIdsTexture) { gl.activeTexture(gl.TEXTURE5); gl.bindTexture(gl.TEXTURE_2D, this.contestIdsTexture); } if (this.contestTimesTexture) { gl.activeTexture(gl.TEXTURE6); gl.bindTexture(gl.TEXTURE_2D, this.contestTimesTexture); } if (fromStateTexture) { gl.activeTexture(gl.TEXTURE7); gl.bindTexture(gl.TEXTURE_2D, fromStateTexture); } 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, seedsOld); } if (seedsNew) { gl.activeTexture(gl.TEXTURE9); gl.bindTexture(gl.TEXTURE_2D, seedsNew); } if (this.stateTexture) { gl.activeTexture(gl.TEXTURE12); gl.bindTexture(gl.TEXTURE_2D, this.stateTexture); } if (this.terrainTexture) { gl.activeTexture(gl.TEXTURE14); gl.bindTexture(gl.TEXTURE_2D, this.terrainTexture); } const changeMaskTexture = renderPair === "olderPrev" ? this.changeMaskTextureOld : this.changeMaskTextureNew; if (changeMaskTexture) { gl.activeTexture(gl.TEXTURE13); gl.bindTexture(gl.TEXTURE_2D, changeMaskTexture); } if (this.contestStrengthsTexture) { gl.activeTexture(gl.TEXTURE11); gl.bindTexture(gl.TEXTURE_2D, this.contestStrengthsTexture); } if (this.uniforms.viewResolution) { gl.uniform2f( this.uniforms.viewResolution, this.viewWidth, this.viewHeight, ); } if (this.uniforms.viewScale) { gl.uniform1f(this.uniforms.viewScale, this.viewScale); } if (this.uniforms.viewOffset) { gl.uniform2f( this.uniforms.viewOffset, this.viewOffsetX, this.viewOffsetY, ); } if (this.uniforms.alternativeView) { gl.uniform1i(this.uniforms.alternativeView, this.alternativeView ? 1 : 0); } if (this.uniforms.hoveredPlayerId) { // Disable highlight after 5 seconds const now = Date.now(); const elapsed = now - this.hoverStartTime; const activeHoverId = this.hoveredPlayerId >= 0 && elapsed < TerritoryWebGLRenderer.HOVER_DURATION_MS ? this.hoveredPlayerId : -1; gl.uniform1f(this.uniforms.hoveredPlayerId, activeHoverId); } if (this.uniforms.hoverHighlightStrength) { gl.uniform1f( this.uniforms.hoverHighlightStrength, this.hoverHighlightStrength, ); } if (this.uniforms.hoverHighlightColor) { const [r, g, b] = this.hoverHighlightColor; gl.uniform3f(this.uniforms.hoverHighlightColor, r, g, b); } if (this.uniforms.hoverPulseStrength) { gl.uniform1f(this.uniforms.hoverPulseStrength, this.hoverPulseStrength); } if (this.uniforms.hoverPulseSpeed) { gl.uniform1f(this.uniforms.hoverPulseSpeed, this.hoverPulseSpeed); } if (this.uniforms.time) { const currentTime = (Date.now() - this.animationStartTime) / 1000.0; gl.uniform1f(this.uniforms.time, currentTime); } if (this.uniforms.viewerId) { const viewerId = this.game.myPlayer()?.smallID() ?? 0; gl.uniform1i(this.uniforms.viewerId, viewerId); } if (this.uniforms.contestEnabled) { gl.uniform1i(this.uniforms.contestEnabled, this.contestEnabled ? 1 : 0); } if (this.uniforms.contestPatternMode) { gl.uniform1i(this.uniforms.contestPatternMode, this.contestPatternMode); } if (this.uniforms.debugDisableStaticBorders) { gl.uniform1i( this.uniforms.debugDisableStaticBorders, this.debugDisableStaticBorders ? 1 : 0, ); } if (this.uniforms.debugDisableAllBorders) { gl.uniform1i( this.uniforms.debugDisableAllBorders, this.debugDisableAllBorders ? 1 : 0, ); } if (this.uniforms.seedSamplingMode) { gl.uniform1i(this.uniforms.seedSamplingMode, this.seedSamplingMode); } if (this.uniforms.debugStripeFixedColors) { gl.uniform1i( this.uniforms.debugStripeFixedColors, this.debugStripeFixedColors ? 1 : 0, ); } if (this.uniforms.motionMode) { gl.uniform1i(this.uniforms.motionMode, this.motionMode); } if (this.uniforms.contestNow) { gl.uniform1i(this.uniforms.contestNow, this.contestNow); } if (this.uniforms.contestDuration) { gl.uniform1f(this.uniforms.contestDuration, this.contestDurationTicks); } if (this.uniforms.smoothProgress) { gl.uniform1f(this.uniforms.smoothProgress, this.smoothProgress); } if (this.uniforms.smoothEnabled) { gl.uniform1i(this.uniforms.smoothEnabled, this.smoothEnabled ? 1 : 0); } if (this.uniforms.darkMode) { gl.uniform1i( this.uniforms.darkMode, this.userSettings.darkMode() ? 1 : 0, ); } gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); gl.drawArrays(gl.TRIANGLES, 0, 6); gl.bindVertexArray(null); FrameProfiler.end("TerritoryWebGLRenderer:draw", renderSpan); } getDebugStats() { return { mapWidth: this.mapWidth, mapHeight: this.mapHeight, viewWidth: this.viewWidth, viewHeight: this.viewHeight, viewScale: this.viewScale, viewOffsetX: this.viewOffsetX, viewOffsetY: this.viewOffsetY, smoothEnabled: this.smoothEnabled, smoothProgress: this.smoothProgress, jfaSupported: this.jfaSupported, jfaDisabledReason: this.jfaDisabledReason, jfaDirty: this.jfaDirty, prevStateCopySupported: this.prevStateCopySupported, contestDurationTicks: this.contestDurationTicks, contestNow: this.contestNow, hoveredPlayerId: this.hoveredPlayerId, }; } private uploadStateTexture(): { rows: number; bytes: number } { if (!this.gl || !this.stateTexture) return { rows: 0, bytes: 0 }; const gl = this.gl; gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.stateTexture); const bytesPerPixel = Uint16Array.BYTES_PER_ELEMENT; let rowsUploaded = 0; let bytesUploaded = 0; if (this.needsFullUpload) { gl.texImage2D( gl.TEXTURE_2D, 0, gl.R16UI, this.mapWidth, this.mapHeight, 0, gl.RED_INTEGER, gl.UNSIGNED_SHORT, this.state, ); this.needsFullUpload = false; this.dirtyRows.clear(); rowsUploaded = this.mapHeight; bytesUploaded = this.mapWidth * this.mapHeight * bytesPerPixel; return { rows: rowsUploaded, bytes: bytesUploaded }; } if (this.dirtyRows.size === 0) { return { rows: 0, bytes: 0 }; } for (const [y, span] of this.dirtyRows) { const width = span.maxX - span.minX + 1; const offset = y * this.mapWidth + span.minX; const rowSlice = this.state.subarray(offset, offset + width); gl.texSubImage2D( gl.TEXTURE_2D, 0, span.minX, y, width, 1, gl.RED_INTEGER, gl.UNSIGNED_SHORT, rowSlice, ); rowsUploaded++; bytesUploaded += width * bytesPerPixel; } this.dirtyRows.clear(); return { rows: rowsUploaded, bytes: bytesUploaded }; } private uploadContestTexture(): { rows: number; bytes: number } { if (!this.gl || !this.contestOwnersTexture || !this.contestIdsTexture) { return { rows: 0, bytes: 0 }; } const gl = this.gl; gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); const bytesPerOwnerPixel = Uint16Array.BYTES_PER_ELEMENT * 2; const bytesPerIdPixel = Uint16Array.BYTES_PER_ELEMENT; let rowsUploaded = 0; let bytesUploaded = 0; if (this.needsContestFullUpload) { gl.activeTexture(gl.TEXTURE4); gl.bindTexture(gl.TEXTURE_2D, this.contestOwnersTexture); gl.texImage2D( gl.TEXTURE_2D, 0, gl.RG16UI, this.mapWidth, this.mapHeight, 0, gl.RG_INTEGER, gl.UNSIGNED_SHORT, this.contestOwnersState, ); gl.activeTexture(gl.TEXTURE5); gl.bindTexture(gl.TEXTURE_2D, this.contestIdsTexture); gl.texImage2D( gl.TEXTURE_2D, 0, gl.R16UI, this.mapWidth, this.mapHeight, 0, gl.RED_INTEGER, gl.UNSIGNED_SHORT, this.contestIdsState, ); this.needsContestFullUpload = false; this.contestDirtyRows.clear(); rowsUploaded = this.mapHeight; bytesUploaded = this.mapWidth * this.mapHeight * (bytesPerOwnerPixel + bytesPerIdPixel); return { rows: rowsUploaded, bytes: bytesUploaded }; } if (this.contestDirtyRows.size === 0) { return { rows: 0, bytes: 0 }; } for (const [y, span] of this.contestDirtyRows) { const width = span.maxX - span.minX + 1; const ownerOffset = (y * this.mapWidth + span.minX) * 2; const ownerSlice = this.contestOwnersState.subarray( ownerOffset, ownerOffset + width * 2, ); gl.activeTexture(gl.TEXTURE4); gl.bindTexture(gl.TEXTURE_2D, this.contestOwnersTexture); gl.texSubImage2D( gl.TEXTURE_2D, 0, span.minX, y, width, 1, gl.RG_INTEGER, gl.UNSIGNED_SHORT, ownerSlice, ); const idOffset = y * this.mapWidth + span.minX; const idSlice = this.contestIdsState.subarray(idOffset, idOffset + width); gl.activeTexture(gl.TEXTURE5); gl.bindTexture(gl.TEXTURE_2D, this.contestIdsTexture); gl.texSubImage2D( gl.TEXTURE_2D, 0, span.minX, y, width, 1, gl.RED_INTEGER, gl.UNSIGNED_SHORT, idSlice, ); rowsUploaded++; bytesUploaded += width * (bytesPerOwnerPixel + bytesPerIdPixel); } this.contestDirtyRows.clear(); return { rows: rowsUploaded, bytes: bytesUploaded }; } private uploadContestTimesTexture(): { rows: number; bytes: number } { if (!this.gl || !this.contestTimesTexture) { return { rows: 0, bytes: 0 }; } if (!this.needsContestTimesUpload) { return { rows: 0, bytes: 0 }; } const gl = this.gl; gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); gl.activeTexture(gl.TEXTURE6); gl.bindTexture(gl.TEXTURE_2D, this.contestTimesTexture); gl.texImage2D( gl.TEXTURE_2D, 0, gl.R16UI, this.contestTimesState.length, 1, 0, gl.RED_INTEGER, gl.UNSIGNED_SHORT, this.contestTimesState, ); this.needsContestTimesUpload = false; const bytes = this.contestTimesState.length * Uint16Array.BYTES_PER_ELEMENT; return { rows: 1, bytes }; } private uploadContestStrengthsTexture(): { rows: number; bytes: number } { if (!this.gl || !this.contestStrengthsTexture) { return { rows: 0, bytes: 0 }; } if (!this.needsContestStrengthsUpload) { return { rows: 0, bytes: 0 }; } const gl = this.gl; gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); gl.activeTexture(gl.TEXTURE11); gl.bindTexture(gl.TEXTURE_2D, this.contestStrengthsTexture); gl.texImage2D( gl.TEXTURE_2D, 0, gl.R16UI, this.contestStrengthsState.length, 1, 0, gl.RED_INTEGER, gl.UNSIGNED_SHORT, this.contestStrengthsState, ); this.needsContestStrengthsUpload = false; const bytes = this.contestStrengthsState.length * Uint16Array.BYTES_PER_ELEMENT; return { rows: 1, bytes }; } private updateChangeMask() { if ( !this.gl || !this.jfaSupported || !this.changeMaskDirty || !this.changeMaskProgram || !this.changeMaskFramebufferNew || !this.changeMaskFramebufferOld || !this.changeMaskFramebufferOlder || !this.prevOwnerTexture || !this.stateTexture || !this.jfaVao ) { return; } const gl = this.gl; const prevBlend = gl.isEnabled(gl.BLEND); gl.disable(gl.BLEND); gl.viewport(0, 0, this.mapWidth, this.mapHeight); gl.bindVertexArray(this.jfaVao); gl.useProgram(this.changeMaskProgram); if (this.changeMaskUniforms.resolution) { gl.uniform2f( this.changeMaskUniforms.resolution, this.mapWidth, this.mapHeight, ); } if (this.changeMaskUniforms.oldTexture) { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.prevOwnerTexture); gl.uniform1i(this.changeMaskUniforms.oldTexture, 0); } if (this.changeMaskUniforms.newTexture) { gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, this.stateTexture); gl.uniform1i(this.changeMaskUniforms.newTexture, 1); } gl.bindFramebuffer(gl.FRAMEBUFFER, this.changeMaskFramebufferNew); gl.drawArrays(gl.TRIANGLES, 0, 6); gl.bindFramebuffer(gl.FRAMEBUFFER, null); if (!this.changeMaskHistoryInitialized) { gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.changeMaskFramebufferNew); gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.changeMaskFramebufferOld); 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.changeMaskFramebufferOlder); 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.changeMaskHistoryInitialized = true; } this.changeMaskDirty = false; if (prevBlend) { gl.enable(gl.BLEND); } } private updateJfa() { if ( !this.gl || !this.jfaSupported || !this.jfaSeedProgram || !this.jfaProgram || !this.jfaFramebufferA || !this.jfaFramebufferB || !this.jfaTextureA || !this.jfaTextureB || !this.stateTexture || !this.jfaResultNewFramebuffer || !this.jfaResultNewTexture || !this.jfaVao ) { return; } if (!this.jfaDirty) { return; } const gl = this.gl; const prevBlend = gl.isEnabled(gl.BLEND); gl.disable(gl.BLEND); gl.viewport(0, 0, this.mapWidth, this.mapHeight); gl.bindVertexArray(this.jfaVao); const runJfa = ( ownerTexture: WebGLTexture, resultFramebuffer: WebGLFramebuffer, ) => { gl.useProgram(this.jfaSeedProgram); if (this.jfaSeedUniforms.resolution) { gl.uniform2f( this.jfaSeedUniforms.resolution, this.mapWidth, this.mapHeight, ); } if (this.jfaSeedUniforms.owner) { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, ownerTexture); gl.uniform1i(this.jfaSeedUniforms.owner, 0); } gl.bindFramebuffer(gl.FRAMEBUFFER, this.jfaFramebufferA); gl.drawArrays(gl.TRIANGLES, 0, 6); let readTex = this.jfaTextureA; let readFbo = this.jfaFramebufferA; let writeFbo = this.jfaFramebufferB; let writeTex = this.jfaTextureB; for (const step of this.jfaSteps) { gl.useProgram(this.jfaProgram); if (this.jfaUniforms.resolution) { gl.uniform2f( this.jfaUniforms.resolution, this.mapWidth, this.mapHeight, ); } if (this.jfaUniforms.step) { gl.uniform1f(this.jfaUniforms.step, step); } if (this.jfaUniforms.seeds) { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, readTex); gl.uniform1i(this.jfaUniforms.seeds, 0); } gl.bindFramebuffer(gl.FRAMEBUFFER, writeFbo); gl.drawArrays(gl.TRIANGLES, 0, 6); const tempTex = readTex; readTex = writeTex; writeTex = tempTex; const tempFbo = readFbo; readFbo = writeFbo; writeFbo = tempFbo; } gl.bindFramebuffer(gl.READ_FRAMEBUFFER, readFbo); gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, resultFramebuffer); gl.blitFramebuffer( 0, 0, this.mapWidth, this.mapHeight, 0, 0, this.mapWidth, this.mapHeight, gl.COLOR_BUFFER_BIT, gl.NEAREST, ); }; 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); } gl.bindVertexArray(null); } private buildJfaSteps(width: number, height: number): number[] { const maxDim = Math.max(width, height); let step = 1; while (step < maxDim) { step <<= 1; } step >>= 1; const steps: number[] = []; while (step >= 1) { steps.push(step); step >>= 1; } return steps; } private uploadPalette() { if ( !this.gl || !this.paletteTexture || !this.relationTexture || !this.patternTexture || !this.program ) return; const gl = this.gl; const players = this.game.playerViews().filter((p) => p.isPlayer()); const maxId = players.reduce((max, p) => Math.max(max, p.smallID()), 0) + 1; this.paletteWidth = Math.max(maxId, 1); const paletteData = new Uint8Array(this.paletteWidth * 8); const relationData = new Uint8Array(this.paletteWidth * this.paletteWidth); const patternData = new Uint8Array( this.paletteWidth * PATTERN_STRIDE_BYTES, ); const patternsEnabled = this.userSettings.territoryPatterns(); const defaultPatternBytes = this.getPatternBytes( DefaultPattern.patternData, ); for (const p of players) { const id = p.smallID(); const territoryRgba = p.territoryColor().rgba; paletteData[id * 8] = territoryRgba.r; paletteData[id * 8 + 1] = territoryRgba.g; paletteData[id * 8 + 2] = territoryRgba.b; paletteData[id * 8 + 3] = Math.round((territoryRgba.a ?? 1) * 255); const borderRgba = p.borderColor().rgba; paletteData[id * 8 + 4] = borderRgba.r; paletteData[id * 8 + 5] = borderRgba.g; paletteData[id * 8 + 6] = borderRgba.b; paletteData[id * 8 + 7] = Math.round((borderRgba.a ?? 1) * 255); const patternBytes = patternsEnabled && p.cosmetics.pattern ? this.getPatternBytes(p.cosmetics.pattern.patternData) : defaultPatternBytes; const offset = id * PATTERN_STRIDE_BYTES; patternData.set(patternBytes.slice(0, PATTERN_STRIDE_BYTES), offset); } for (let ownerId = 0; ownerId < this.paletteWidth; ownerId++) { const owner = this.safePlayerBySmallId(ownerId); for (let otherId = 0; otherId < this.paletteWidth; otherId++) { const other = this.safePlayerBySmallId(otherId); relationData[ownerId * this.paletteWidth + otherId] = this.resolveRelationCode(owner, other); } } gl.useProgram(this.program); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture); 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.RGBA8, this.paletteWidth * 2, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, paletteData, ); gl.activeTexture(gl.TEXTURE2); gl.bindTexture(gl.TEXTURE_2D, this.relationTexture); 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.R8UI, this.paletteWidth, this.paletteWidth, 0, gl.RED_INTEGER, gl.UNSIGNED_BYTE, relationData, ); gl.activeTexture(gl.TEXTURE3); gl.bindTexture(gl.TEXTURE_2D, this.patternTexture); 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.R8UI, PATTERN_STRIDE_BYTES, this.paletteWidth, 0, gl.RED_INTEGER, gl.UNSIGNED_BYTE, patternData, ); if (this.uniforms.patternStride) { gl.uniform1i(this.uniforms.patternStride, PATTERN_STRIDE_BYTES); } if (this.uniforms.patternRows) { gl.uniform1i(this.uniforms.patternRows, this.paletteWidth); } } private resolveRelationCode( owner: PlayerView | null, other: PlayerView | null, ): number { if (!owner || !other || !owner.isPlayer() || !other.isPlayer()) { return 0; } let code = 0; if (owner.smallID() === other.smallID()) { code |= 4; } if (owner.isFriendly(other) || other.isFriendly(owner)) { code |= 1; } if (owner.hasEmbargo(other)) { code |= 2; } return code; } private safePlayerBySmallId(id: number): PlayerView | null { const player = this.game.playerBySmallID(id); return player instanceof PlayerView ? player : null; } private getPatternBytes(patternData: string): Uint8Array { const cached = this.patternBytesCache.get(patternData); if (cached) { return cached; } try { const bytes = base64url.decode(patternData); this.patternBytesCache.set(patternData, bytes); return bytes; } catch (error) { const fallback = base64url.decode(DefaultPattern.patternData); this.patternBytesCache.set(patternData, fallback); return fallback; } } private createJfaSeedProgram( gl: WebGL2RenderingContext, ): WebGLProgram | null { const vertexShaderSource = `#version 300 es precision highp float; layout(location = 0) in vec2 a_position; uniform vec2 u_resolution; void main() { vec2 zeroToOne = a_position / u_resolution; vec2 clipSpace = zeroToOne * 2.0 - 1.0; clipSpace.y = -clipSpace.y; gl_Position = vec4(clipSpace, 0.0, 1.0); } `; const fragmentShaderSource = `#version 300 es precision highp float; precision highp usampler2D; uniform usampler2D u_ownerTexture; uniform vec2 u_resolution; out vec2 outSeed; uint ownerAt(ivec2 texCoord) { ivec2 clamped = clamp( texCoord, ivec2(0, 0), ivec2(int(u_resolution.x) - 1, int(u_resolution.y) - 1) ); return texelFetch(u_ownerTexture, clamped, 0).r & 0xFFFu; } void main() { ivec2 fragCoord = ivec2(gl_FragCoord.xy); ivec2 texCoord = ivec2( fragCoord.x, int(u_resolution.y) - 1 - fragCoord.y ); uint owner = ownerAt(texCoord); bool isBorder = false; vec2 edgeDir = vec2(0.0); uint nOwner = ownerAt(texCoord + ivec2(1, 0)); if (nOwner != owner) { isBorder = true; edgeDir += vec2(1.0, 0.0); } nOwner = ownerAt(texCoord + ivec2(-1, 0)); if (nOwner != owner) { isBorder = true; edgeDir += vec2(-1.0, 0.0); } nOwner = ownerAt(texCoord + ivec2(0, 1)); if (nOwner != owner) { isBorder = true; edgeDir += vec2(0.0, 1.0); } nOwner = ownerAt(texCoord + ivec2(0, -1)); if (nOwner != owner) { isBorder = true; edgeDir += vec2(0.0, -1.0); } vec2 edgeOffset = vec2( edgeDir.x == 0.0 ? 0.0 : (edgeDir.x > 0.0 ? 0.5 : -0.5), edgeDir.y == 0.0 ? 0.0 : (edgeDir.y > 0.0 ? 0.5 : -0.5) ); // Seed at the border edge (tile center +/- 0.5) so the front can move // even when the border tile itself stays the same. outSeed = isBorder ? (vec2(texCoord) + vec2(0.5) + edgeOffset) : vec2(-1.0, -1.0); } `; const vertexShader = this.compileShader( gl, gl.VERTEX_SHADER, vertexShaderSource, ); const fragmentShader = this.compileShader( gl, gl.FRAGMENT_SHADER, fragmentShaderSource, ); if (!vertexShader || !fragmentShader) { return null; } const program = gl.createProgram(); if (!program) return null; gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error( "[TerritoryWebGLRenderer] JFA seed link error", gl.getProgramInfoLog(program), ); gl.deleteProgram(program); return null; } return program; } private createJfaProgram(gl: WebGL2RenderingContext): WebGLProgram | null { const vertexShaderSource = `#version 300 es precision highp float; layout(location = 0) in vec2 a_position; uniform vec2 u_resolution; void main() { vec2 zeroToOne = a_position / u_resolution; vec2 clipSpace = zeroToOne * 2.0 - 1.0; clipSpace.y = -clipSpace.y; gl_Position = vec4(clipSpace, 0.0, 1.0); } `; const fragmentShaderSource = `#version 300 es precision highp float; uniform sampler2D u_seeds; uniform vec2 u_resolution; uniform float u_step; out vec2 outSeed; vec2 seedAt(ivec2 coord) { // coord is in texCoord space (Y-flipped from fragCoord) // JFA texture was written at fragCoord positions, so flip back ivec2 jfaCoord = ivec2(coord.x, int(u_resolution.y) - 1 - coord.y); ivec2 clamped = clamp( jfaCoord, ivec2(0, 0), ivec2(int(u_resolution.x) - 1, int(u_resolution.y) - 1) ); return texelFetch(u_seeds, clamped, 0).rg; } void considerSeed(ivec2 coord, ivec2 texCoord, inout vec2 bestSeed, inout float bestDist) { vec2 seed = seedAt(coord); if (seed.x < 0.0) { return; } float dist = length(seed - (vec2(texCoord) + vec2(0.5))); if (dist < bestDist) { bestDist = dist; bestSeed = seed; } } void main() { ivec2 fragCoord = ivec2(gl_FragCoord.xy); ivec2 texCoord = ivec2( fragCoord.x, int(u_resolution.y) - 1 - fragCoord.y ); int step = int(u_step + 0.5); vec2 bestSeed = seedAt(texCoord); vec2 texPos = vec2(texCoord) + vec2(0.5); float bestDist = bestSeed.x < 0.0 ? 1e20 : length(bestSeed - texPos); considerSeed(texCoord + ivec2(-step, -step), texCoord, bestSeed, bestDist); considerSeed(texCoord + ivec2(0, -step), texCoord, bestSeed, bestDist); considerSeed(texCoord + ivec2(step, -step), texCoord, bestSeed, bestDist); considerSeed(texCoord + ivec2(-step, 0), texCoord, bestSeed, bestDist); considerSeed(texCoord + ivec2(step, 0), texCoord, bestSeed, bestDist); considerSeed(texCoord + ivec2(-step, step), texCoord, bestSeed, bestDist); considerSeed(texCoord + ivec2(0, step), texCoord, bestSeed, bestDist); considerSeed(texCoord + ivec2(step, step), texCoord, bestSeed, bestDist); outSeed = bestSeed; } `; const vertexShader = this.compileShader( gl, gl.VERTEX_SHADER, vertexShaderSource, ); const fragmentShader = this.compileShader( gl, gl.FRAGMENT_SHADER, fragmentShaderSource, ); if (!vertexShader || !fragmentShader) { return null; } const program = gl.createProgram(); if (!program) return null; gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error( "[TerritoryWebGLRenderer] JFA link error", gl.getProgramInfoLog(program), ); gl.deleteProgram(program); return null; } return program; } private createChangeMaskProgram( gl: WebGL2RenderingContext, ): WebGLProgram | null { const vertexShaderSource = `#version 300 es precision highp float; layout(location = 0) in vec2 a_position; uniform vec2 u_resolution; void main() { vec2 zeroToOne = a_position / u_resolution; vec2 clipSpace = zeroToOne * 2.0 - 1.0; clipSpace.y = -clipSpace.y; gl_Position = vec4(clipSpace, 0.0, 1.0); } `; const fragmentShaderSource = `#version 300 es precision highp float; precision highp usampler2D; uniform usampler2D u_oldTexture; uniform usampler2D u_newTexture; uniform vec2 u_resolution; layout(location = 0) out uint outMask; uint ownerAt(usampler2D tex, ivec2 texCoord) { ivec2 clamped = clamp( texCoord, ivec2(0, 0), ivec2(int(u_resolution.x) - 1, int(u_resolution.y) - 1) ); return texelFetch(tex, clamped, 0).r & 0xFFFu; } void main() { ivec2 fragCoord = ivec2(gl_FragCoord.xy); ivec2 texCoord = ivec2( fragCoord.x, int(u_resolution.y) - 1 - fragCoord.y ); bool changed = ownerAt(u_oldTexture, texCoord) != ownerAt(u_newTexture, texCoord); changed = changed || (ownerAt(u_oldTexture, texCoord + ivec2(1, 0)) != ownerAt(u_newTexture, texCoord + ivec2(1, 0))); changed = changed || (ownerAt(u_oldTexture, texCoord + ivec2(-1, 0)) != ownerAt(u_newTexture, texCoord + ivec2(-1, 0))); changed = changed || (ownerAt(u_oldTexture, texCoord + ivec2(0, 1)) != ownerAt(u_newTexture, texCoord + ivec2(0, 1))); changed = changed || (ownerAt(u_oldTexture, texCoord + ivec2(0, -1)) != ownerAt(u_newTexture, texCoord + ivec2(0, -1))); outMask = changed ? 1u : 0u; } `; const vertexShader = this.compileShader( gl, gl.VERTEX_SHADER, vertexShaderSource, ); const fragmentShader = this.compileShader( gl, gl.FRAGMENT_SHADER, fragmentShaderSource, ); if (!vertexShader || !fragmentShader) { return null; } const program = gl.createProgram(); if (!program) return null; gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error( "[TerritoryWebGLRenderer] change mask link error", gl.getProgramInfoLog(program), ); gl.deleteProgram(program); return null; } return program; } private createProgram(gl: WebGL2RenderingContext): WebGLProgram | null { const vertexShaderSource = `#version 300 es precision highp float; layout(location = 0) in vec2 a_position; uniform vec2 u_viewResolution; void main() { vec2 zeroToOne = a_position / u_viewResolution; vec2 clipSpace = zeroToOne * 2.0 - 1.0; clipSpace.y = -clipSpace.y; gl_Position = vec4(clipSpace, 0.0, 1.0); } `; const fragmentShaderSource = `#version 300 es precision highp float; precision highp usampler2D; uniform usampler2D u_state; uniform usampler2D u_terrain; uniform usampler2D u_latestState; uniform sampler2D u_palette; uniform usampler2D u_relations; uniform usampler2D u_patterns; uniform bool u_contestEnabled; uniform int u_contestPatternMode; // 0=blueNoise(strength), 1=checkerboard(50/50), 2=bayer4x4(strength) uniform bool u_debugDisableStaticBorders; uniform bool u_debugDisableAllBorders; uniform int u_seedSamplingMode; // 0=none(single texel), 1=2x2, 2=3x3 uniform bool u_debugStripeFixedColors; // Use fixed debug colors for moving stripe uniform int u_motionMode; // 0=euclidean, 1=axisSnap, 2=manhattan, 3=chebyshev uniform usampler2D u_contestOwners; uniform usampler2D u_contestIds; uniform usampler2D u_contestTimes; uniform usampler2D u_contestStrengths; uniform bool u_jfaAvailable; uniform int u_contestNow; uniform float u_contestDurationTicks; uniform usampler2D u_prevOwner; uniform usampler2D u_changeMask; uniform sampler2D u_jfaSeedsOld; uniform sampler2D u_jfaSeedsNew; uniform float u_smoothProgress; uniform bool u_smoothEnabled; uniform int u_patternStride; uniform int u_patternRows; uniform int u_viewerId; uniform vec2 u_mapResolution; uniform vec2 u_viewResolution; uniform float u_viewScale; uniform vec2 u_viewOffset; 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 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 bool u_darkMode; out vec4 outColor; uint stateAtTex(ivec2 texCoord) { ivec2 clamped = clamp( texCoord, ivec2(0, 0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1) ); return texelFetch(u_state, clamped, 0).r; } uint ownerAtTex(ivec2 texCoord) { return stateAtTex(texCoord) & 0xFFFu; } // Terrain bit layout: bit7=land, bit6=shoreline, bit5=ocean, bits0-4=magnitude uint terrainAtTex(ivec2 texCoord) { ivec2 clamped = clamp( texCoord, ivec2(0, 0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1) ); return texelFetch(u_terrain, clamped, 0).r; } bool isLand(uint terrain) { return (terrain & 0x80u) != 0u; // bit 7 } bool isShoreline(uint terrain) { return (terrain & 0x40u) != 0u; // bit 6 } bool isOcean(uint terrain) { return (terrain & 0x20u) != 0u; // bit 5 } uint getMagnitude(uint terrain) { return terrain & 0x1Fu; // bits 0-4 } // Compute terrain color based on type, magnitude, and theme // Colors match PastelTheme (light) and PastelThemeDark exactly vec3 terrainColor(uint terrain) { uint mag = getMagnitude(terrain); float fmag = float(mag); if (isLand(terrain)) { if (isShoreline(terrain)) { // Shore/beach - land adjacent to water // Light: rgb(204,203,158), Dark: rgb(134,133,88) return u_darkMode ? vec3(134.0/255.0, 133.0/255.0, 88.0/255.0) : vec3(204.0/255.0, 203.0/255.0, 158.0/255.0); } if (mag < 10u) { // Plains (mag 0-9) // Light: rgb(190, 220-2*mag, 138), Dark: rgb(140, 170-2*mag, 88) return u_darkMode ? vec3(140.0/255.0, (170.0 - 2.0*fmag)/255.0, 88.0/255.0) : vec3(190.0/255.0, (220.0 - 2.0*fmag)/255.0, 138.0/255.0); } else if (mag < 20u) { // Highland (mag 10-19) // Light: rgb(200+2*mag, 183+2*mag, 138+2*mag) // Dark: rgb(150+2*mag, 133+2*mag, 88+2*mag) return u_darkMode ? vec3((150.0 + 2.0*fmag)/255.0, (133.0 + 2.0*fmag)/255.0, (88.0 + 2.0*fmag)/255.0) : vec3((200.0 + 2.0*fmag)/255.0, (183.0 + 2.0*fmag)/255.0, (138.0 + 2.0*fmag)/255.0); } else { // Mountain (mag 20-30) // Light: rgb(230+mag/2, 230+mag/2, 230+mag/2) // Dark: rgb(180+mag/2, 180+mag/2, 180+mag/2) float base = u_darkMode ? 180.0 : 230.0; float val = (base + fmag/2.0) / 255.0; return vec3(val, val, val); } } else { // Water if (isShoreline(terrain)) { // Shoreline water - lighter, adjacent to land // Light: rgb(100,143,255), Dark: rgb(50,50,50) return u_darkMode ? vec3(50.0/255.0, 50.0/255.0, 50.0/255.0) : vec3(100.0/255.0, 143.0/255.0, 255.0/255.0); } if (isOcean(terrain)) { // Ocean - depth-adjusted // Light base: rgb(70,132,180), adjusted by +1-min(mag,10) // Dark base: rgb(14,11,30), adjusted by +9-mag for mag<10 float depthAdj = float(min(mag, 10u)); if (u_darkMode) { // Dark: rgb(14+9-mag, 11+9-mag, 30+9-mag) for mag<10, else rgb(14,11,30) if (mag < 10u) { return vec3( (14.0 + 9.0 - fmag)/255.0, (11.0 + 9.0 - fmag)/255.0, (30.0 + 9.0 - fmag)/255.0 ); } return vec3(14.0/255.0, 11.0/255.0, 30.0/255.0); } else { // Light: rgb(70-10+11-min(mag,10), 132-10+11-min(mag,10), 180-10+11-min(mag,10)) // = rgb(71-depthAdj, 133-depthAdj, 181-depthAdj) return vec3( (71.0 - depthAdj)/255.0, (133.0 - depthAdj)/255.0, (181.0 - depthAdj)/255.0 ); } } else { // Lake - use same as shoreline water for simplicity // Light: rgb(100,143,255), Dark: rgb(50,50,50) return u_darkMode ? vec3(50.0/255.0, 50.0/255.0, 50.0/255.0) : vec3(100.0/255.0, 143.0/255.0, 255.0/255.0); } } } uint prevStateAtTex(ivec2 texCoord) { ivec2 clamped = clamp( texCoord, ivec2(0, 0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1) ); return texelFetch(u_prevOwner, clamped, 0).r; } uint prevOwnerAtTex(ivec2 texCoord) { return prevStateAtTex(texCoord) & 0xFFFu; } vec2 jfaSeedOldAtTex(ivec2 texCoord) { // JFA texture was written with fragCoord (bottom-left origin), but we're reading with // texCoord (top-left origin, same as state texture). Need to flip Y to match. // JFA row 0 = fragCoord.y=0 = stateTexCoord.y=height-1 = bottom of map // To read data for texCoord.y=0 (top), we need JFA row height-1 ivec2 flipped = ivec2(texCoord.x, int(u_mapResolution.y) - 1 - texCoord.y); ivec2 clamped = clamp( flipped, ivec2(0, 0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1) ); return texelFetch(u_jfaSeedsOld, clamped, 0).rg; } vec2 jfaSeedNewAtTex(ivec2 texCoord) { // JFA texture was written with fragCoord (bottom-left origin), but we're reading with // texCoord (top-left origin, same as state texture). Need to flip Y to match. ivec2 flipped = ivec2(texCoord.x, int(u_mapResolution.y) - 1 - texCoord.y); ivec2 clamped = clamp( flipped, ivec2(0, 0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1) ); return texelFetch(u_jfaSeedsNew, clamped, 0).rg; } // Best-of-NxN seed sampling to reduce tile-boundary discontinuities. // Returns the seed (from OLD JFA) that is closest to mapCoord. vec2 bestSeedOld(vec2 mapCoord) { ivec2 base = ivec2(floor(mapCoord)); float bestDist = 1e9; vec2 bestSeed = vec2(-1.0); int radius = u_seedSamplingMode == 2 ? 1 : 0; // 3x3 vs 2x2 int end = u_seedSamplingMode == 2 ? 2 : 2; // 3x3: -1..+1, 2x2: 0..+1 int start = u_seedSamplingMode == 2 ? -1 : 0; for (int dy = start; dy < end; dy++) { for (int dx = start; dx < end; dx++) { ivec2 sampleTex = base + ivec2(dx, dy); vec2 seed = jfaSeedOldAtTex(sampleTex); if (seed.x >= 0.0) { float d = distance(mapCoord, seed); if (d < bestDist) { bestDist = d; bestSeed = seed; } } } } return bestSeed; } // Best-of-NxN seed sampling for NEW JFA. vec2 bestSeedNew(vec2 mapCoord) { ivec2 base = ivec2(floor(mapCoord)); float bestDist = 1e9; vec2 bestSeed = vec2(-1.0); int radius = u_seedSamplingMode == 2 ? 1 : 0; int end = u_seedSamplingMode == 2 ? 2 : 2; int start = u_seedSamplingMode == 2 ? -1 : 0; for (int dy = start; dy < end; dy++) { for (int dx = start; dx < end; dx++) { ivec2 sampleTex = base + ivec2(dx, dy); vec2 seed = jfaSeedNewAtTex(sampleTex); if (seed.x >= 0.0) { float d = distance(mapCoord, seed); if (d < bestDist) { bestDist = d; bestSeed = seed; } } } } return bestSeed; } uvec2 contestOwnersAtTex(ivec2 texCoord) { ivec2 clamped = clamp( texCoord, ivec2(0, 0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1) ); return texelFetch(u_contestOwners, clamped, 0).rg; } uint contestIdRawAtTex(ivec2 texCoord) { ivec2 clamped = clamp( texCoord, ivec2(0, 0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1) ); return texelFetch(u_contestIds, clamped, 0).r; } float contestStrength(uint contestId) { if (contestId == 0u) { return 0.5; } uint strengthRaw = texelFetch( u_contestStrengths, ivec2(int(contestId), 0), 0 ).r; return clamp(float(strengthRaw) / 65535.0, 0.0, 1.0); } float blueNoise(ivec2 texCoord) { vec2 p = vec2(texCoord); float x = fract(0.06711056 * p.x + 0.00583715 * p.y); return fract(52.9829189 * x); } float bayer4x4(ivec2 texCoord) { // Classic 4x4 Bayer matrix values 0..15 mapped to (0.5/16 .. 15.5/16) int x = texCoord.x & 3; int y = texCoord.y & 3; int idx = (y << 2) | x; int v = 0; // Row-major: // 0 8 2 10 // 12 4 14 6 // 3 11 1 9 // 15 7 13 5 if (idx == 0) v = 0; else if (idx == 1) v = 8; else if (idx == 2) v = 2; else if (idx == 3) v = 10; else if (idx == 4) v = 12; else if (idx == 5) v = 4; else if (idx == 6) v = 14; else if (idx == 7) v = 6; else if (idx == 8) v = 3; else if (idx == 9) v = 11; else if (idx == 10) v = 1; else if (idx == 11) v = 9; else if (idx == 12) v = 15; else if (idx == 13) v = 7; else if (idx == 14) v = 13; else v = 5; return (float(v) + 0.5) / 16.0; } bool contestPickAttacker(ivec2 texCoord, float strength) { if (u_contestPatternMode == 1) { // Checkerboard is always 50/50 (ignores strength) return ((texCoord.x + texCoord.y) & 1) == 0; } if (u_contestPatternMode == 2) { return bayer4x4(texCoord) < strength; } return blueNoise(texCoord) < strength; } uint relationCode(uint owner, uint other) { if (owner == 0u || other == 0u) { return 0u; } return texelFetch(u_relations, ivec2(int(owner), int(other)), 0).r; } bool isFriendly(uint code) { return (code & 1u) != 0u; } bool isEmbargo(uint code) { return (code & 2u) != 0u; } bool isSelf(uint code) { return (code & 4u) != 0u; } uint patternByte(uint owner, uint offset) { int x = int(offset); int y = int(owner); if (x < 0 || x >= u_patternStride || y < 0 || y >= u_patternRows) { return 0u; } return texelFetch(u_patterns, ivec2(x, y), 0).r; } bool patternIsPrimary(uint owner, ivec2 texCoord) { uint version = patternByte(owner, 0u); if (version != 0u) { return true; } uint b1 = patternByte(owner, 1u); uint b2 = patternByte(owner, 2u); uint scale = b1 & 7u; uint width = (((b2 & 3u) << 5) | ((b1 >> 3) & 31u)) + 2u; uint height = ((b2 >> 2) & 63u) + 2u; if (width == 0u || height == 0u) { return true; } uint px = (uint(texCoord.x) >> scale) % width; uint py = (uint(texCoord.y) >> scale) % height; uint idx = py * width + px; uint byteIndex = idx >> 3; uint bitIndex = idx & 7u; uint byteVal = patternByte(owner, 3u + byteIndex); return (byteVal & (1u << bitIndex)) == 0u; } vec3 applyDefended(vec3 color, bool defended, ivec2 texCoord) { if (!defended) { return color; } bool isLightTile = ((texCoord.x % 2) == (texCoord.y % 2)); const float LIGHT_FACTOR = 1.2; const float DARK_FACTOR = 0.8; return color * (isLightTile ? LIGHT_FACTOR : DARK_FACTOR); } vec3 applyBorderTint(vec3 color, bool hasFriendly, bool hasEmbargo) { const float BORDER_TINT_RATIO = 0.35; const vec3 FRIENDLY_TINT_TARGET = vec3(0.0, 1.0, 0.0); const vec3 EMBARGO_TINT_TARGET = vec3(1.0, 0.0, 0.0); if (hasFriendly) { color = color * (1.0 - BORDER_TINT_RATIO) + FRIENDLY_TINT_TARGET * BORDER_TINT_RATIO; } if (hasEmbargo) { color = color * (1.0 - BORDER_TINT_RATIO) + EMBARGO_TINT_TARGET * BORDER_TINT_RATIO; } return color; } void main() { // gl_FragCoord.xy is already at pixel center (0.5, 0.5 ...). // Use the pixel center to avoid half-pixel snapping/offset artifacts, // especially noticeable on the interpolated JFA border/front. vec2 viewCoord = vec2( gl_FragCoord.x - 0.5, u_viewResolution.y - gl_FragCoord.y - 0.5 ); vec2 mapHalf = u_mapResolution * 0.5; vec2 mapCoord = (viewCoord - mapHalf) / u_viewScale + u_viewOffset + mapHalf; if ( mapCoord.x < 0.0 || mapCoord.y < 0.0 || mapCoord.x >= u_mapResolution.x || mapCoord.y >= u_mapResolution.y ) { outColor = vec4(0.0); return; } // Tile centers are at (0.5, 1.5, 2.5, ...). Floor gives the tile index. // Original ivec2(mapCoord) is equivalent but less explicit. ivec2 texCoord = ivec2(mapCoord); uint state = stateAtTex(texCoord); 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 oldState = prevStateAtTex(texCoord); uint oldOwner = oldState & 0xFFFu; bool oldHasFallout = (oldState & 0x2000u) != 0u; bool oldIsDefended = (oldState & 0x1000u) != 0u; // ChangeMask was written with Y-flipped coords, so flip when reading ivec2 changeMaskCoord = ivec2(texCoord.x, int(u_mapResolution.y) - 1 - texCoord.y); uint changeMask = texelFetch(u_changeMask, changeMaskCoord, 0).r; // Expand the animation region by 1 tile (halo) so the *outer* border edge can move smoothly. // If we only animate "changed" tiles, the leading edge stays pinned to tile coordinates because // neighbor pixels are still rendered from the static FROM snapshot. uint affectedMask = changeMask; ivec2 cm; cm = ivec2(clamp(texCoord.x + 1, 0, int(u_mapResolution.x) - 1), texCoord.y); affectedMask |= texelFetch(u_changeMask, ivec2(cm.x, int(u_mapResolution.y) - 1 - cm.y), 0).r; cm = ivec2(clamp(texCoord.x - 1, 0, int(u_mapResolution.x) - 1), texCoord.y); affectedMask |= texelFetch(u_changeMask, ivec2(cm.x, int(u_mapResolution.y) - 1 - cm.y), 0).r; cm = ivec2(texCoord.x, clamp(texCoord.y + 1, 0, int(u_mapResolution.y) - 1)); affectedMask |= texelFetch(u_changeMask, ivec2(cm.x, int(u_mapResolution.y) - 1 - cm.y), 0).r; cm = ivec2(texCoord.x, clamp(texCoord.y - 1, 0, int(u_mapResolution.y) - 1)); affectedMask |= texelFetch(u_changeMask, ivec2(cm.x, int(u_mapResolution.y) - 1 - cm.y), 0).r; bool smoothActive = u_smoothEnabled && u_smoothProgress < 1.0 && !u_alternativeView && u_jfaAvailable && affectedMask != 0u; uint contestIdRaw = 0u; const uint CONTEST_ID_MASK = 0x7FFFu; uint contestId = 0u; uvec2 contestOwners = uvec2(0u); uint defender = 0u; bool contested = false; if (u_contestEnabled) { contestIdRaw = contestIdRawAtTex(texCoord); contestId = contestIdRaw & CONTEST_ID_MASK; contestOwners = contestOwnersAtTex(texCoord); defender = contestOwners.r & 0xFFFu; if (contestId != 0u) { uint lastTime = texelFetch(u_contestTimes, ivec2(int(contestId), 0), 0).r; const uint CONTEST_WRAP = 32768u; uint nowTime = uint(u_contestNow); uint elapsed = nowTime >= lastTime ? (nowTime - lastTime) : (CONTEST_WRAP - lastTime + nowTime); contested = float(elapsed) < u_contestDurationTicks; } } // Border detection: check if any neighbor has a different owner. bool isBorder = false; bool hasFriendlyRelation = false; bool hasEmbargoRelation = false; if (!smoothActive) { 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); } } // Get terrain for background rendering (needed for both normal and alt view) uint terrain = terrainAtTex(texCoord); vec3 baseTerrainColor = terrainColor(terrain); if (u_alternativeView) { // Alt view: terrain + borders only, no territory fill vec3 color = baseTerrainColor; if (owner == 0u && hasFallout) { color = mix(baseTerrainColor, u_fallout.rgb, u_alpha); } if (!u_debugDisableAllBorders && !u_debugDisableStaticBorders && owner != 0u && isBorder) { // Only draw borders, not territory fill 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; } 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, 1.0); return; } // Normal view: blend territory on top of terrain vec3 fillColor = baseTerrainColor; vec3 borderColor = vec3(0.0); float borderAlpha = 0.0; vec3 ownerBase = vec3(0.0); vec4 ownerBorder = vec4(0.0); if (owner == 0u) { // Unowned tile - show terrain (or fallout if irradiated) if (hasFallout) { // Blend fallout on top of terrain fillColor = mix(baseTerrainColor, u_fallout.rgb, u_alpha); } // Otherwise fillColor is already baseTerrainColor } else { vec4 base = texelFetch(u_palette, ivec2(int(owner) * 2, 0), 0); vec4 baseBorder = texelFetch( u_palette, ivec2(int(owner) * 2 + 1, 0), 0 ); ownerBase = base.rgb; ownerBorder = baseBorder; bool isPrimary = patternIsPrimary(owner, texCoord); vec3 patternColor = isPrimary ? base.rgb : baseBorder.rgb; // Blend territory fill on top of terrain fillColor = mix(baseTerrainColor, patternColor, u_alpha); if (isBorder && !smoothActive) { vec3 bColor = applyBorderTint( baseBorder.rgb, hasFriendlyRelation, hasEmbargoRelation ); borderColor = applyDefended(bColor, isDefended, texCoord); borderAlpha = baseBorder.a; } } vec3 color = fillColor; 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, ivec2(int(defender) * 2, 0), 0 ); defenderBase = defenderColor.rgb; } float strength = contestStrength(contestId); bool pickAttacker = contestPickAttacker(texCoord, strength); vec3 contestColor = pickAttacker ? latestOwnerBase : defenderBase; // Blend contested fill on top of terrain color = mix(baseTerrainColor, contestColor, u_alpha); } if (!u_debugDisableAllBorders && !u_debugDisableStaticBorders && !smoothActive && isBorder && owner != 0u) { // Blend border on top of the current fill color = mix(color, borderColor, borderAlpha); } if (smoothActive) { // DEBUG: uncomment ONE line to visualize issues // color = vec3(1.0, 0.0, 1.0); outColor = vec4(color, 1.0); return; // magenta = smoothActive tiles // vec2 ds = jfaSeedOldAtTex(texCoord); color = vec3(ds.x >= 0.0 ? 0.0 : 1.0, jfaSeedNewAtTex(texCoord).x >= 0.0 ? 0.0 : 1.0, 0.0); outColor = vec4(color, 1.0); return; // seed validity // Compute old color blended on terrain vec3 oldColor = baseTerrainColor; if (oldOwner == 0u) { if (oldHasFallout) { oldColor = mix(baseTerrainColor, u_fallout.rgb, u_alpha); } // Otherwise oldColor is already baseTerrainColor } else { vec4 oldBase = texelFetch(u_palette, ivec2(int(oldOwner) * 2, 0), 0); vec4 oldBorder = texelFetch( u_palette, ivec2(int(oldOwner) * 2 + 1, 0), 0 ); bool oldPrimary = patternIsPrimary(oldOwner, texCoord); vec3 oldPatternColor = oldPrimary ? oldBase.rgb : oldBorder.rgb; oldColor = mix(baseTerrainColor, oldPatternColor, u_alpha); } // JFA-based animation with tile-sized pixelated look // Movement is pixel-smooth but edges remain hard/blocky like stable borders // Use best-of-NxN seed sampling when enabled to reduce tile-boundary discontinuities. // Use seeds picked at the TILE CENTER to avoid seed flipping inside a tile // (which can cause direction/timing glitches). Distances still use mapCoord // for smooth within-tile variation. vec2 tileCenter = floor(mapCoord) + 0.5; vec2 seedOld = u_seedSamplingMode == 0 ? jfaSeedOldAtTex(texCoord) : bestSeedOld(tileCenter); vec2 seedNew = u_seedSamplingMode == 0 ? jfaSeedNewAtTex(texCoord) : bestSeedNew(tileCenter); bool hasOldSeed = seedOld.x >= 0.0; bool hasNewSeed = seedNew.x >= 0.0; // CORRECT MODEL (no blending, no "future"): // - We are interpolating between a *pair* of snapshots (from/to), selected by "renderPair" on CPU. // - u_prevOwner is the FROM snapshot (texture unit 7). // - u_state is the TO snapshot (texture unit 0). // - u_jfaSeedsOld/u_jfaSeedsNew + u_changeMask also match that pair. // // We render: // 1) Old snapshot at the true map coords (static). // 2) New snapshot slid in from the old border position toward the new border position. // No blending: the slid-in new snapshot overwrites old ONLY where changeMask indicates change. float t = clamp(u_smoothProgress, 0.0, 1.0); // --- Old layer (FROM snapshot), at texCoord --- uint fromState = oldState; uint fromOwner = oldOwner; // Fill for FROM owner vec3 fromColor = baseTerrainColor; if (fromOwner != 0u) { vec4 fromBase = texelFetch(u_palette, ivec2(int(fromOwner) * 2, 0), 0); vec4 fromBorderBase = texelFetch( u_palette, ivec2(int(fromOwner) * 2 + 1, 0), 0 ); bool fromPrimary = patternIsPrimary(fromOwner, texCoord); vec3 fromPatternColor = fromPrimary ? fromBase.rgb : fromBorderBase.rgb; fromColor = mix(baseTerrainColor, fromPatternColor, u_alpha); } else if (oldHasFallout) { // preserve fallout tint when unowned fromColor = mix(baseTerrainColor, u_fallout.rgb, u_alpha); } // Border for FROM owner (tile-width, stable look) bool fromIsBorder = false; uint fromOther = 0u; uint nFrom; nFrom = texelFetch(u_prevOwner, texCoord + ivec2(1, 0), 0).r & 0xFFFu; if (nFrom != fromOwner) { fromIsBorder = true; if (nFrom != 0u) fromOther = nFrom; } nFrom = texelFetch(u_prevOwner, texCoord + ivec2(-1, 0), 0).r & 0xFFFu; if (nFrom != fromOwner) { fromIsBorder = true; if (nFrom != 0u) fromOther = nFrom; } nFrom = texelFetch(u_prevOwner, texCoord + ivec2(0, 1), 0).r & 0xFFFu; if (nFrom != fromOwner) { fromIsBorder = true; if (nFrom != 0u) fromOther = nFrom; } nFrom = texelFetch(u_prevOwner, texCoord + ivec2(0, -1), 0).r & 0xFFFu; if (nFrom != fromOwner) { fromIsBorder = true; if (nFrom != 0u) fromOther = nFrom; } if (!u_debugDisableAllBorders && !u_debugDisableStaticBorders && fromIsBorder && fromOwner != 0u) { vec4 borderBase = texelFetch(u_palette, ivec2(int(fromOwner) * 2 + 1, 0), 0); bool fromFriendly = false; bool fromEmbargo = false; if (fromOther != 0u) { uint rel = relationCode(fromOwner, fromOther); fromFriendly = isFriendly(rel); fromEmbargo = isEmbargo(rel); } vec3 bColor = applyBorderTint( borderBase.rgb, fromFriendly, fromEmbargo ); bColor = applyDefended(bColor, oldIsDefended, texCoord); fromColor = bColor; } // Start with FROM layer color = fromColor; // Draw a *constant-width* moving border stripe between the FROM and TO snapshots. // Use a planar front (not radial) that moves coherently across tiles based on // the displacement direction from old->new seeds. if (affectedMask != 0u && hasOldSeed && hasNewSeed) { vec2 disp = seedNew - seedOld; vec2 absDisp = abs(disp); vec2 dispSign = vec2(disp.x >= 0.0 ? 1.0 : -1.0, disp.y >= 0.0 ? 1.0 : -1.0); float dispLen = length(disp); if (dispLen > 1e-4) { vec2 dir = vec2(1.0, 0.0); vec2 frontOrigin = seedOld; float frontPos = 0.0; vec2 shift = vec2(0.0); if (u_motionMode == 1) { bool xDom = absDisp.x >= absDisp.y; dir = xDom ? vec2(dispSign.x, 0.0) : vec2(0.0, dispSign.y); float len = xDom ? absDisp.x : absDisp.y; frontOrigin = seedOld; frontPos = t * len; shift = dir * (len * (1.0 - t)); } else if (u_motionMode == 2) { bool xDom = absDisp.x >= absDisp.y; vec2 axisX = vec2(dispSign.x, 0.0); vec2 axisY = vec2(0.0, dispSign.y); vec2 axis1 = xDom ? axisX : axisY; vec2 axis2 = xDom ? axisY : axisX; float len1 = xDom ? absDisp.x : absDisp.y; float len2 = xDom ? absDisp.y : absDisp.x; float total = len1 + len2; float split = total > 1e-4 ? len1 / total : 0.5; if (t <= split) { float t1 = split > 1e-4 ? t / split : 1.0; dir = axis1; frontOrigin = seedOld; frontPos = t1 * len1; shift = axis1 * (len1 * (1.0 - t1)) + axis2 * len2; } else { float t2 = (t - split) / max(1.0 - split, 1e-4); dir = axis2; frontOrigin = seedOld + axis1 * len1; frontPos = t2 * len2; shift = axis2 * (len2 * (1.0 - t2)); } } else if (u_motionMode == 3) { float maxAbs = max(absDisp.x, absDisp.y); float p = t * maxAbs; vec2 remaining = max(absDisp - vec2(p), vec2(0.0)); shift = dispSign * remaining; bool xDom = absDisp.x >= absDisp.y; dir = xDom ? vec2(dispSign.x, 0.0) : vec2(0.0, dispSign.y); frontOrigin = seedOld; frontPos = t * maxAbs; } else { dir = disp / dispLen; frontOrigin = seedOld; frontPos = t * dispLen; shift = disp * (1.0 - t); } // Project mapCoord onto the displacement direction, measured from frontOrigin. // This gives us a global coordinate along the motion axis. // At t=0, front should be near frontOrigin (s ~ 0). // At t=1, front should be near frontOrigin + dir * frontPos. float s = dot(mapCoord - frontOrigin, dir); // Signed distance from the moving front plane. // Positive means the front has passed this point (new territory side). float frontDist = frontPos - s; // Compute the sliding position: sample owners at the position where the front currently is. // This ensures owner checks happen at the sliding position, not static. vec2 slideOffsetFront = (frontPos - s) * dir; // Offset from current position to front position vec2 slideCoordFront = mapCoord + slideOffsetFront; ivec2 slideTexFront = clamp(ivec2(slideCoordFront), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); // Sample owners at the sliding position uint slideState = texelFetch(u_state, slideTexFront, 0).r; uint slideOwner = slideState & 0xFFFu; bool slideHasFallout = (slideState & 0x2000u) != 0u; bool slideIsDefended = (slideState & 0x1000u) != 0u; // Check if we're on a border at the sliding position (this is where the border currently is) bool slideIsBorder = false; bool slideHasFriendly = false; bool slideHasEmbargo = false; uint slideOther = 0u; uint nSlide; ivec2 nSlideTex; nSlideTex = clamp(slideTexFront + ivec2(1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); nSlide = texelFetch(u_state, nSlideTex, 0).r & 0xFFFu; if (nSlide != slideOwner) { slideIsBorder = true; if (nSlide != 0u) { slideOther = nSlide; uint rel = relationCode(slideOwner, nSlide); slideHasFriendly = slideHasFriendly || isFriendly(rel); slideHasEmbargo = slideHasEmbargo || isEmbargo(rel); } } nSlideTex = clamp(slideTexFront + ivec2(-1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); nSlide = texelFetch(u_state, nSlideTex, 0).r & 0xFFFu; if (nSlide != slideOwner) { slideIsBorder = true; if (nSlide != 0u) { slideOther = nSlide; uint rel = relationCode(slideOwner, nSlide); slideHasFriendly = slideHasFriendly || isFriendly(rel); slideHasEmbargo = slideHasEmbargo || isEmbargo(rel); } } nSlideTex = clamp(slideTexFront + ivec2(0, 1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); nSlide = texelFetch(u_state, nSlideTex, 0).r & 0xFFFu; if (nSlide != slideOwner) { slideIsBorder = true; if (nSlide != 0u) { slideOther = nSlide; uint rel = relationCode(slideOwner, nSlide); slideHasFriendly = slideHasFriendly || isFriendly(rel); slideHasEmbargo = slideHasEmbargo || isEmbargo(rel); } } nSlideTex = clamp(slideTexFront + ivec2(0, -1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); nSlide = texelFetch(u_state, nSlideTex, 0).r & 0xFFFu; if (nSlide != slideOwner) { slideIsBorder = true; if (nSlide != 0u) { slideOther = nSlide; uint rel = relationCode(slideOwner, nSlide); slideHasFriendly = slideHasFriendly || isFriendly(rel); slideHasEmbargo = slideHasEmbargo || isEmbargo(rel); } } // Check if we're on a border in the FROM state (retreating side) uint fromSlideState = prevStateAtTex(slideTexFront); uint fromSlideOwner = fromSlideState & 0xFFFu; bool fromSlideDefended = (fromSlideState & 0x1000u) != 0u; bool fromIsBorderAtSlide = false; uint fromOtherAtSlide = 0u; uint nFromSlide; ivec2 nFromSlideTex; nFromSlideTex = clamp(slideTexFront + ivec2(1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); nFromSlide = texelFetch(u_prevOwner, nFromSlideTex, 0).r & 0xFFFu; if (nFromSlide != fromSlideOwner) { fromIsBorderAtSlide = true; if (nFromSlide != 0u) fromOtherAtSlide = nFromSlide; } nFromSlideTex = clamp(slideTexFront + ivec2(-1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); nFromSlide = texelFetch(u_prevOwner, nFromSlideTex, 0).r & 0xFFFu; if (nFromSlide != fromSlideOwner) { fromIsBorderAtSlide = true; if (nFromSlide != 0u) fromOtherAtSlide = nFromSlide; } nFromSlideTex = clamp(slideTexFront + ivec2(0, 1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); nFromSlide = texelFetch(u_prevOwner, nFromSlideTex, 0).r & 0xFFFu; if (nFromSlide != fromSlideOwner) { fromIsBorderAtSlide = true; if (nFromSlide != 0u) fromOtherAtSlide = nFromSlide; } nFromSlideTex = clamp(slideTexFront + ivec2(0, -1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); nFromSlide = texelFetch(u_prevOwner, nFromSlideTex, 0).r & 0xFFFu; if (nFromSlide != fromSlideOwner) { fromIsBorderAtSlide = true; if (nFromSlide != 0u) fromOtherAtSlide = nFromSlide; } // Draw border stripe: check both expanding (TO) and retreating (FROM) sides float stripeWidth = u_debugDisableAllBorders ? 0.0 : 0.5; bool isStripe = abs(frontDist) <= stripeWidth; bool drawExpandingBorder = isStripe && slideIsBorder && slideOwner != 0u && frontDist > 0.0; bool drawRetreatingBorder = isStripe && fromIsBorderAtSlide && fromSlideOwner != 0u && frontDist <= 0.0; if (!u_debugDisableAllBorders && (drawExpandingBorder || drawRetreatingBorder)) { uint stripeOwner = drawExpandingBorder ? slideOwner : fromSlideOwner; uint stripeOther = drawExpandingBorder ? slideOther : fromOtherAtSlide; if (u_debugStripeFixedColors) { // Debug mode: Use fixed colors if (drawExpandingBorder) { // Expanding: bright red color = vec3(1.0, float(stripeOwner) / 255.0, 0.0); } else { // Retreating: bright blue color = vec3(0.0, float(stripeOwner) / 255.0, 1.0); } } else { // Normal mode: Use actual border colors if (stripeOwner != 0u) { vec4 borderBase = texelFetch( u_palette, ivec2(int(stripeOwner) * 2 + 1, 0), 0 ); bool stripeFriendly = false; bool stripeEmbargo = false; if (stripeOther != 0u) { uint rel = relationCode(stripeOwner, stripeOther); stripeFriendly = isFriendly(rel); stripeEmbargo = isEmbargo(rel); } bool stripeDefended = drawExpandingBorder ? slideIsDefended : fromSlideDefended; vec3 bColor = applyBorderTint( borderBase.rgb, stripeFriendly, stripeEmbargo ); bColor = applyDefended(bColor, stripeDefended, slideTexFront); color = bColor; } } } else if (frontDist > stripeWidth) { // Front has passed; show the new fill/border at the shifted position vec2 slideCoordFill = mapCoord - shift; ivec2 slideTexFill = clamp(ivec2(slideCoordFill), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); uint fillState = texelFetch(u_state, slideTexFill, 0).r; uint fillOwner = fillState & 0xFFFu; bool fillHasFallout = (fillState & 0x2000u) != 0u; bool fillIsDefended = (fillState & 0x1000u) != 0u; bool fillIsBorder = false; bool fillHasFriendly = false; bool fillHasEmbargo = false; uint fillOther = 0u; uint nFill; ivec2 nFillTex; nFillTex = clamp(slideTexFill + ivec2(1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); nFill = texelFetch(u_state, nFillTex, 0).r & 0xFFFu; if (nFill != fillOwner) { fillIsBorder = true; if (nFill != 0u) { fillOther = nFill; uint rel = relationCode(fillOwner, nFill); fillHasFriendly = fillHasFriendly || isFriendly(rel); fillHasEmbargo = fillHasEmbargo || isEmbargo(rel); } } nFillTex = clamp(slideTexFill + ivec2(-1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); nFill = texelFetch(u_state, nFillTex, 0).r & 0xFFFu; if (nFill != fillOwner) { fillIsBorder = true; if (nFill != 0u) { fillOther = nFill; uint rel = relationCode(fillOwner, nFill); fillHasFriendly = fillHasFriendly || isFriendly(rel); fillHasEmbargo = fillHasEmbargo || isEmbargo(rel); } } nFillTex = clamp(slideTexFill + ivec2(0, 1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); nFill = texelFetch(u_state, nFillTex, 0).r & 0xFFFu; if (nFill != fillOwner) { fillIsBorder = true; if (nFill != 0u) { fillOther = nFill; uint rel = relationCode(fillOwner, nFill); fillHasFriendly = fillHasFriendly || isFriendly(rel); fillHasEmbargo = fillHasEmbargo || isEmbargo(rel); } } nFillTex = clamp(slideTexFill + ivec2(0, -1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)); nFill = texelFetch(u_state, nFillTex, 0).r & 0xFFFu; if (nFill != fillOwner) { fillIsBorder = true; if (nFill != 0u) { fillOther = nFill; uint rel = relationCode(fillOwner, nFill); fillHasFriendly = fillHasFriendly || isFriendly(rel); fillHasEmbargo = fillHasEmbargo || isEmbargo(rel); } } vec3 toColor = baseTerrainColor; if (fillOwner != 0u) { vec4 toBase = texelFetch(u_palette, ivec2(int(fillOwner) * 2, 0), 0); vec4 toBorderBase = texelFetch( u_palette, ivec2(int(fillOwner) * 2 + 1, 0), 0 ); bool toPrimary = patternIsPrimary(fillOwner, slideTexFill); vec3 toPatternColor = toPrimary ? toBase.rgb : toBorderBase.rgb; toColor = mix(baseTerrainColor, toPatternColor, u_alpha); if (!u_debugDisableAllBorders && !u_debugDisableStaticBorders && fillIsBorder) { vec3 bColor = applyBorderTint( toBorderBase.rgb, fillHasFriendly, fillHasEmbargo ); bColor = applyDefended(bColor, fillIsDefended, slideTexFill); toColor = bColor; } } else if (fillHasFallout) { toColor = mix(baseTerrainColor, u_fallout.rgb, u_alpha); } color = toColor; } // If frontDist < -stripeWidth, we're ahead of the front, so keep fromColor (already set). } } } bool pendingOwnerChange = latestOwner != owner; if (pendingOwnerChange && !useContestedFill && !u_alternativeView) { vec3 hintColor = baseTerrainColor; if (latestOwner != 0u) { vec3 latestColor = texelFetch( u_palette, ivec2(int(latestOwner) * 2, 0), 0 ).rgb; hintColor = mix(baseTerrainColor, latestColor, u_alpha * 0.12); } color = mix(color, hintColor, 0.5); } 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); } // Output fully opaque since we render terrain as background outColor = vec4(color, 1.0); } `; const vertexShader = this.compileShader( gl, gl.VERTEX_SHADER, vertexShaderSource, ); const fragmentShader = this.compileShader( gl, gl.FRAGMENT_SHADER, fragmentShaderSource, ); if (!vertexShader || !fragmentShader) { return null; } const program = gl.createProgram(); if (!program) return null; gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error( "[TerritoryWebGLRenderer] link error", gl.getProgramInfoLog(program), ); gl.deleteProgram(program); return null; } return program; } private compileShader( gl: WebGL2RenderingContext, type: number, source: string, ): WebGLShader | null { const shader = gl.createShader(type); if (!shader) return null; gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { console.error( "[TerritoryWebGLRenderer] shader error", gl.getShaderInfoLog(shader), ); gl.deleteShader(shader); return null; } return shader; } }