From 990b6988454319cf25d7221f79a9da970aa6071a Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Fri, 28 Nov 2025 14:34:37 +0100 Subject: [PATCH] fixes --- src/client/graphics/layers/TerritoryLayer.ts | 43 ++++-- .../graphics/layers/TerritoryWebGLRenderer.ts | 136 +++++++++++++++++- src/core/game/GameView.ts | 5 +- 3 files changed, 173 insertions(+), 11 deletions(-) diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index da3c35d82..4255b0806 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -169,7 +169,11 @@ export class TerritoryLayer implements Layer { const focusedPlayer = this.game.focusedPlayer(); if (focusedPlayer !== this.lastFocusedPlayer) { - if (!this.territoryRenderer) { + if (this.territoryRenderer) { + // Force a full repaint so the GPU textures match the new focus context + // (e.g., when jumping to another location during spawn). + this.redraw(); + } else { if (this.lastFocusedPlayer) { this.paintPlayerBorder(this.lastFocusedPlayer); } @@ -294,6 +298,7 @@ export class TerritoryLayer implements Layer { this.eventBus.on(AlternateViewEvent, (e) => { this.alternativeView = e.alternateView; this.territoryRenderer?.setAlternativeView(this.alternativeView); + this.territoryRenderer?.markAllDirty(); this.territoryRenderer?.setHoverHighlightOptions( this.hoverHighlightOptions(), ); @@ -385,11 +390,15 @@ export class TerritoryLayer implements Layer { ); this.initImageData(); - this.context.putImageData( - this.alternativeView ? this.alternativeImageData : this.imageData, - 0, - 0, - ); + if (!this.territoryRenderer) { + this.context.putImageData( + this.alternativeView ? this.alternativeImageData : this.imageData, + 0, + 0, + ); + } else { + this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); + } this.configureRenderers(); @@ -515,6 +524,7 @@ export class TerritoryLayer implements Layer { renderLayer(context: CanvasRenderingContext2D) { const now = Date.now(); + // When WebGL is available, rely entirely on the GPU renderer (even in alt view). const gpuTerritoryActive = this.territoryRenderer !== null; const skipTerritoryCanvas = gpuTerritoryActive; @@ -540,7 +550,7 @@ export class TerritoryLayer implements Layer { // territory buffer (alternativeImageData) is effectively transparent and // all visible work is done by the WebGL layer. Skip putImageData in that // case to avoid unnecessary CPU work each frame. - const shouldBlitTerritories = !skipTerritoryCanvas && !gpuTerritoryActive; + const shouldBlitTerritories = !gpuTerritoryActive && !skipTerritoryCanvas; if (w > 0 && h > 0 && shouldBlitTerritories) { const putImageStart = FrameProfiler.start(); @@ -630,7 +640,13 @@ export class TerritoryLayer implements Layer { const cpuStart = FrameProfiler.start(); const useGpuTerritory = this.territoryRenderer !== null; const hasOwner = this.game.hasOwner(tile); - const owner = hasOwner ? (this.game.owner(tile) as PlayerView) : null; + const rawOwner = hasOwner ? this.game.owner(tile) : null; + const owner = + rawOwner && + typeof (rawOwner as any).isPlayer === "function" && + (rawOwner as any).isPlayer() + ? (rawOwner as PlayerView) + : null; const isBorderTile = this.game.isBorder(tile); const hasFallout = this.game.hasFallout(tile); let isDefended = false; @@ -645,6 +661,17 @@ export class TerritoryLayer implements Layer { if (useGpuTerritory) { this.territoryRenderer?.markTile(tile); + if (!owner || !isBorderTile) { + this.territoryRenderer?.clearBorderColor(tile); + } else { + const borderCol = owner.borderColor(tile, isDefended).rgba; + this.territoryRenderer?.setBorderColor(tile, { + r: borderCol.r, + g: borderCol.g, + b: borderCol.b, + a: Math.round((borderCol.a ?? 1) * 255), + }); + } } else { if (!owner) { if (hasFallout) { diff --git a/src/client/graphics/layers/TerritoryWebGLRenderer.ts b/src/client/graphics/layers/TerritoryWebGLRenderer.ts index 5e5720a28..691c6d806 100644 --- a/src/client/graphics/layers/TerritoryWebGLRenderer.ts +++ b/src/client/graphics/layers/TerritoryWebGLRenderer.ts @@ -33,11 +33,13 @@ export class TerritoryWebGLRenderer { private readonly stateTexture: WebGLTexture | null; private readonly paletteTexture: WebGLTexture | null; private readonly relationTexture: WebGLTexture | null; + private readonly borderColorTexture: WebGLTexture | null; private readonly uniforms: { resolution: WebGLUniformLocation | null; state: WebGLUniformLocation | null; palette: WebGLUniformLocation | null; relations: WebGLUniformLocation | null; + borderColor: WebGLUniformLocation | null; fallout: WebGLUniformLocation | null; altSelf: WebGLUniformLocation | null; altAlly: WebGLUniformLocation | null; @@ -55,7 +57,9 @@ export class TerritoryWebGLRenderer { private readonly state: Uint16Array; private readonly dirtyRows: Map = new Map(); + private readonly borderDirtyRows: Map = new Map(); private needsFullUpload = true; + private borderNeedsFullUpload = true; private alternativeView = false; private paletteWidth = 0; private hoverHighlightStrength = 0.7; @@ -64,6 +68,7 @@ export class TerritoryWebGLRenderer { private hoverPulseSpeed = Math.PI * 2; private hoveredPlayerId = -1; private animationStartTime = Date.now(); + private borderColorData: Uint8Array; private constructor( private readonly game: GameView, @@ -75,6 +80,9 @@ export class TerritoryWebGLRenderer { this.canvas.height = game.height(); this.state = new Uint16Array(sharedState); + this.borderColorData = new Uint8Array( + this.canvas.width * this.canvas.height * 4, + ); this.gl = this.canvas.getContext("webgl2", { premultipliedAlpha: true, @@ -89,11 +97,13 @@ export class TerritoryWebGLRenderer { this.stateTexture = null; this.paletteTexture = null; this.relationTexture = null; + this.borderColorTexture = null; this.uniforms = { resolution: null, state: null, palette: null, relations: null, + borderColor: null, fallout: null, altSelf: null, altAlly: null, @@ -119,11 +129,13 @@ export class TerritoryWebGLRenderer { this.stateTexture = null; this.paletteTexture = null; this.relationTexture = null; + this.borderColorTexture = null; this.uniforms = { resolution: null, state: null, palette: null, relations: null, + borderColor: null, fallout: null, altSelf: null, altAlly: null, @@ -146,6 +158,7 @@ export class TerritoryWebGLRenderer { state: gl.getUniformLocation(this.program, "u_state"), palette: gl.getUniformLocation(this.program, "u_palette"), relations: gl.getUniformLocation(this.program, "u_relations"), + borderColor: gl.getUniformLocation(this.program, "u_borderColor"), fallout: gl.getUniformLocation(this.program, "u_fallout"), altSelf: gl.getUniformLocation(this.program, "u_altSelf"), altAlly: gl.getUniformLocation(this.program, "u_altAlly"), @@ -220,12 +233,33 @@ export class TerritoryWebGLRenderer { this.state, ); + this.borderColorTexture = gl.createTexture(); + gl.activeTexture(gl.TEXTURE3); + gl.bindTexture(gl.TEXTURE_2D, this.borderColorTexture); + 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.canvas.width, + this.canvas.height, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + this.borderColorData, + ); + this.uploadPalette(); gl.useProgram(this.program); gl.uniform1i(this.uniforms.state, 0); gl.uniform1i(this.uniforms.palette, 1); gl.uniform1i(this.uniforms.relations, 2); + gl.uniform1i(this.uniforms.borderColor, 3); if (this.uniforms.resolution) { gl.uniform2f( @@ -352,6 +386,27 @@ export class TerritoryWebGLRenderer { this.alternativeView = enabled; } + setBorderColor( + tile: TileRef, + rgba: { r: number; g: number; b: number; a: number }, + ) { + const offset = tile * 4; + this.borderColorData[offset] = rgba.r; + this.borderColorData[offset + 1] = rgba.g; + this.borderColorData[offset + 2] = rgba.b; + this.borderColorData[offset + 3] = rgba.a; + this.markBorderDirty(tile); + } + + clearBorderColor(tile: TileRef) { + const offset = tile * 4; + this.borderColorData[offset] = 0; + this.borderColorData[offset + 1] = 0; + this.borderColorData[offset + 2] = 0; + this.borderColorData[offset + 3] = 0; + this.markBorderDirty(tile); + } + setHoveredPlayerId(playerSmallId: number | null) { const encoded = playerSmallId ?? -1; this.hoveredPlayerId = encoded; @@ -391,9 +446,26 @@ export class TerritoryWebGLRenderer { } } + private markBorderDirty(tile: TileRef) { + if (this.borderNeedsFullUpload) { + return; + } + const x = tile % this.canvas.width; + const y = Math.floor(tile / this.canvas.width); + const span = this.borderDirtyRows.get(y); + if (span === undefined) { + this.borderDirtyRows.set(y, { minX: x, maxX: x }); + } else { + span.minX = Math.min(span.minX, x); + span.maxX = Math.max(span.maxX, x); + } + } + markAllDirty() { this.needsFullUpload = true; this.dirtyRows.clear(); + this.borderNeedsFullUpload = true; + this.borderDirtyRows.clear(); } refreshPalette() { @@ -411,6 +483,7 @@ export class TerritoryWebGLRenderer { const uploadSpan = FrameProfiler.start(); this.uploadStateTexture(); + this.uploadBorderTexture(); FrameProfiler.end("TerritoryWebGLRenderer:uploadState", uploadSpan); const renderSpan = FrameProfiler.start(); @@ -444,6 +517,8 @@ export class TerritoryWebGLRenderer { gl.uniform1f(this.uniforms.time, currentTime); } + 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); @@ -495,6 +570,55 @@ export class TerritoryWebGLRenderer { this.dirtyRows.clear(); } + private uploadBorderTexture() { + if (!this.gl || !this.borderColorTexture) return; + const gl = this.gl; + gl.activeTexture(gl.TEXTURE3); + gl.bindTexture(gl.TEXTURE_2D, this.borderColorTexture); + + if (this.borderNeedsFullUpload) { + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA8, + this.canvas.width, + this.canvas.height, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + this.borderColorData, + ); + this.borderNeedsFullUpload = false; + this.borderDirtyRows.clear(); + return; + } + + if (this.borderDirtyRows.size === 0) { + return; + } + + for (const [y, span] of this.borderDirtyRows) { + const width = span.maxX - span.minX + 1; + const offset = (y * this.canvas.width + span.minX) * 4; + const rowSlice = this.borderColorData.subarray( + offset, + offset + width * 4, + ); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + span.minX, + y, + width, + 1, + gl.RGBA, + gl.UNSIGNED_BYTE, + rowSlice, + ); + } + this.borderDirtyRows.clear(); + } + private uploadPalette() { if (!this.gl || !this.paletteTexture || !this.relationTexture) return; const gl = this.gl; @@ -596,6 +720,7 @@ export class TerritoryWebGLRenderer { uniform usampler2D u_state; uniform sampler2D u_palette; uniform usampler2D u_relations; + uniform sampler2D u_borderColor; uniform vec2 u_resolution; uniform vec4 u_fallout; uniform vec4 u_altSelf; @@ -675,8 +800,17 @@ export class TerritoryWebGLRenderer { } vec4 base = texelFetch(u_palette, ivec2(int(owner), 0), 0); - float a = isBorder ? 1.0 : u_alpha; + vec4 borderColor = texelFetch(u_borderColor, texCoord, 0); vec3 color = base.rgb; + float a = u_alpha; + + if (isBorder && borderColor.a > 0.0) { + color = borderColor.rgb; + a = borderColor.a; + } + if (isBorder && borderColor.a <= 0.0) { + a = 1.0; + } if (u_hoveredPlayerId >= 0.0 && abs(float(owner) - u_hoveredPlayerId) < 0.5) { float pulse = u_hoverPulseStrength > 0.0 diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 63dedc0eb..58ee22b8d 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -586,6 +586,7 @@ export class GameView implements GameMap { private _map: GameMap; private readonly usesSharedTileState: boolean; + private readonly terraNullius = new TerraNulliusImpl(); constructor( public worker: WorkerClient, @@ -741,11 +742,11 @@ export class GameView implements GameMap { playerBySmallID(id: number): PlayerView | TerraNullius { if (id === 0) { - return new TerraNulliusImpl(); + return this.terraNullius; } const playerId = this.smallIDToID.get(id); if (playerId === undefined) { - throw new Error(`small id ${id} not found`); + return this.terraNullius; } return this.player(playerId); }