From ed928db0815248af53a247af7bd21357698cf314 Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Tue, 19 May 2026 00:48:05 +0200 Subject: [PATCH] Display territory skins again (#3966) ## Description: Display territory skins (patterns) again. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: tryout33 --- src/client/ClientGameRunner.ts | 6 ++ src/client/WebGLFrameBuilder.ts | 33 ++++++++- src/client/render/gl/GameView.ts | 12 +++- src/client/render/gl/RenderSettings.ts | 1 + src/client/render/gl/Renderer.ts | 69 ++++++++++++++++++- src/client/render/gl/passes/TerritoryPass.ts | 25 ++++++- src/client/render/gl/render-settings.json | 1 + .../shaders/map-overlay/territory.frag.glsl | 26 +++++++ 8 files changed, 167 insertions(+), 6 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 0f8ffb364..bd1bbd27c 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -451,6 +451,12 @@ async function createClientGame( // setAltView called to switch passes into alt mode. eventBus.on(AlternateViewEvent, (e) => view.setAltView(e.alternateView)); + view.setShowPatterns(userSettings.territoryPatterns()); + globalThis.addEventListener( + `${USER_SETTINGS_CHANGED_EVENT}:settings.territoryPatterns`, + (e) => view.setShowPatterns((e as CustomEvent).detail === "true"), + ); + const gameRenderer = createRenderer( inputOverlay, gameView, diff --git a/src/client/WebGLFrameBuilder.ts b/src/client/WebGLFrameBuilder.ts index 69cf31b0e..b4a6b2b65 100644 --- a/src/client/WebGLFrameBuilder.ts +++ b/src/client/WebGLFrameBuilder.ts @@ -1,4 +1,6 @@ import { Colord } from "colord"; +import { base64url } from "jose"; +import { decodePatternData } from "../core/PatternDecoder"; import { PlayerType } from "../core/game/Game"; import { GameView } from "../core/game/GameView"; import { uploadFrameData } from "./render/frame/Upload"; @@ -23,6 +25,9 @@ const PALETTE_SIZE = 4096; */ export class WebGLFrameBuilder { private readonly palette: Float32Array; + private readonly patternMeta: Float32Array; + private readonly patternData: Uint8Array; + private readonly knownSmallIDs = new Set(); // The renderer needs to know which player is "me" so affiliation tint, // unit colors, and SAM-radius perspective work. Push it once the local @@ -33,6 +38,8 @@ export class WebGLFrameBuilder { constructor(private readonly view: WebGLGameView) { this.palette = new Float32Array(PALETTE_SIZE * 2 * 4); + this.patternMeta = new Float32Array(PALETTE_SIZE * 4); + this.patternData = new Uint8Array(PALETTE_SIZE * 1024); } update(gameView: GameView): void { @@ -116,6 +123,25 @@ export class WebGLFrameBuilder { this.writePaletteEntry(smallID, p.territoryColor(), p.borderColor()); + const pattern = p.cosmetics.pattern; + if (pattern && pattern.patternData) { + try { + const decoded = decodePatternData( + pattern.patternData, + base64url.decode, + ); + const metaOff = smallID * 4; + this.patternMeta[metaOff] = 1.0; // hasPattern = true + this.patternMeta[metaOff + 1] = decoded.width; + this.patternMeta[metaOff + 2] = decoded.height; + this.patternMeta[metaOff + 3] = decoded.scale; + + this.patternData.set(decoded.bytes.slice(3), smallID * 1024); + } catch (e) { + console.warn("Failed to decode territory pattern", e); + } + } + newPlayers.push({ ...p.static, flag: p.cosmetics.flag, @@ -123,7 +149,12 @@ export class WebGLFrameBuilder { }); } if (newPlayers.length > 0) { - this.view.addPlayers(newPlayers, this.palette); + this.view.addPlayers( + newPlayers, + this.palette, + this.patternMeta, + this.patternData, + ); } } diff --git a/src/client/render/gl/GameView.ts b/src/client/render/gl/GameView.ts index 1d2a33202..6d33241ce 100644 --- a/src/client/render/gl/GameView.ts +++ b/src/client/render/gl/GameView.ts @@ -217,8 +217,13 @@ export class GameView { updatePalette(paletteData: Float32Array): void { this.renderer.updatePalette(paletteData); } - addPlayers(players: PlayerStatic[], paletteData: Float32Array): void { - this.renderer.addPlayers(players, paletteData); + addPlayers( + players: PlayerStatic[], + paletteData: Float32Array, + patternMeta: Float32Array, + patternData: Uint8Array, + ): void { + this.renderer.addPlayers(players, paletteData, patternMeta, patternData); } uploadRailroadState(data: Uint8Array): void { this.renderer.uploadRailroadState(data); @@ -328,6 +333,9 @@ export class GameView { setAltView(active: boolean): void { this.renderer.setAltView(active); } + setShowPatterns(active: boolean): void { + this.renderer.setShowPatterns(active); + } setHighlightOwner(ownerID: number): void { this.renderer.setHighlightOwner(ownerID); } diff --git a/src/client/render/gl/RenderSettings.ts b/src/client/render/gl/RenderSettings.ts index 2aa9cfc60..fc76c240e 100644 --- a/src/client/render/gl/RenderSettings.ts +++ b/src/client/render/gl/RenderSettings.ts @@ -4,6 +4,7 @@ export interface RenderSettings { passEnabled: { terrain: boolean; mapOverlay: boolean; + territoryPatterns: boolean; structure: boolean; unit: boolean; name: boolean; diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index b0450eec5..6e6041349 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -127,6 +127,8 @@ export class GPURenderer { private paletteTex: WebGLTexture; private paletteData: Float32Array; + private patternMetaTex: WebGLTexture; + private patternDataTex: WebGLTexture; private canvas: HTMLCanvasElement; private settings: RenderSettings; private sceneTarget: RenderTarget; @@ -219,6 +221,26 @@ export class GPURenderer { filter: gl.NEAREST, }); + this.patternMetaTex = createTexture2D(gl, { + width: palW, + height: 1, + internalFormat: gl.RGBA32F, + format: gl.RGBA, + type: gl.FLOAT, + data: new Float32Array(palW * 4), + filter: gl.NEAREST, + }); + + this.patternDataTex = createTexture2D(gl, { + width: 1024, + height: palW, + internalFormat: gl.R8UI, + format: gl.RED_INTEGER, + type: gl.UNSIGNED_BYTE, + data: new Uint8Array(palW * 1024), + filter: gl.NEAREST, + }); + // --- Border compute (creates its own borderTex) --- // Need a temporary tileTex reference for border compute — we'll create // GPUResources first, then wire everything. @@ -259,7 +281,7 @@ export class GPURenderer { this.settings, ); - // --- Territory (needs tileTex, trailTex, paletteTex) --- + // --- Territory (needs tileTex, trailTex, paletteTex, patternTexs) --- this.territoryPass = new TerritoryPass( gl, mapW, @@ -267,6 +289,8 @@ export class GPURenderer { this.res.tileTex, this.res.trailTex, this.paletteTex, + this.patternMetaTex, + this.patternDataTex, this.settings, ); @@ -582,8 +606,43 @@ export class GPURenderer { } /** Register late-arriving players (updates palette + NamePass lookup maps). */ - addPlayers(players: PlayerStatic[], paletteData: Float32Array): void { + addPlayers( + players: PlayerStatic[], + paletteData: Float32Array, + patternMeta: Float32Array, + patternData: Uint8Array, + ): void { this.updatePalette(paletteData); + + const gl = this.gl; + const palW = getPaletteSize(); + + gl.bindTexture(gl.TEXTURE_2D, this.patternMetaTex); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + palW, + 1, + gl.RGBA, + gl.FLOAT, + patternMeta, + ); + + gl.bindTexture(gl.TEXTURE_2D, this.patternDataTex); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + 1024, + palW, + gl.RED_INTEGER, + gl.UNSIGNED_BYTE, + patternData, + ); + this.namePass.addPlayers(players, this.paletteData); for (const p of players) { if (p.team !== null) this.playerTeams.set(p.smallID, p.team); @@ -838,6 +897,10 @@ export class GPURenderer { this.trailPass.setAltView(active); } + setShowPatterns(active: boolean): void { + this.territoryPass.setShowPatterns(active); + } + setGridView(active: boolean): void { this.gridView = active; } @@ -1147,6 +1210,8 @@ export class GPURenderer { this.barPass.dispose(); disposeGPUResources(this.gl, this.res); this.gl.deleteTexture(this.paletteTex); + this.gl.deleteTexture(this.patternMetaTex); + this.gl.deleteTexture(this.patternDataTex); this.gl.deleteFramebuffer(this.sceneTarget.fbo); this.gl.deleteTexture(this.sceneTarget.tex); this.lastUnits = new Map(); diff --git a/src/client/render/gl/passes/TerritoryPass.ts b/src/client/render/gl/passes/TerritoryPass.ts index 246950746..1edbd8653 100644 --- a/src/client/render/gl/passes/TerritoryPass.ts +++ b/src/client/render/gl/passes/TerritoryPass.ts @@ -36,14 +36,18 @@ export class TerritoryPass { private uCharcoalAlpha: WebGLUniformLocation; private uHighlightOwner: WebGLUniformLocation; private uHighlightBrighten: WebGLUniformLocation; + private uShowPatterns: WebGLUniformLocation; private highlightOwner = 0; private vao: WebGLVertexArrayObject; private tileTex: WebGLTexture; private trailTex: WebGLTexture; private paletteTex: WebGLTexture; + private patternMetaTex: WebGLTexture; + private patternDataTex: WebGLTexture; private altView = false; + private showPatterns = true; /** CPU-side tile state (deltas written here, flushed to GPU before draw). */ private cpuTileState: Uint16Array; @@ -72,6 +76,8 @@ export class TerritoryPass { tileTex: WebGLTexture, trailTex: WebGLTexture, paletteTex: WebGLTexture, + patternMetaTex: WebGLTexture, + patternDataTex: WebGLTexture, settings: RenderSettings, ) { this.gl = gl; @@ -81,6 +87,8 @@ export class TerritoryPass { this.tileTex = tileTex; this.trailTex = trailTex; this.paletteTex = paletteTex; + this.patternMetaTex = patternMetaTex; + this.patternDataTex = patternDataTex; this.cpuTileState = new Uint16Array(mapW * mapH); this.cpuTrailState = new Uint8Array(mapW * mapH); @@ -112,10 +120,13 @@ export class TerritoryPass { this.program, "uHighlightBrighten", )!; + this.uShowPatterns = gl.getUniformLocation(this.program, "uShowPatterns")!; gl.useProgram(this.program); gl.uniform1i(gl.getUniformLocation(this.program, "uTileTex"), 0); gl.uniform1i(gl.getUniformLocation(this.program, "uPalette"), 1); + gl.uniform1i(gl.getUniformLocation(this.program, "uPatternMeta"), 2); + gl.uniform1i(gl.getUniformLocation(this.program, "uPatternData"), 3); this.vao = createMapQuad(gl, mapW, mapH); } @@ -330,6 +341,10 @@ export class TerritoryPass { this.altView = active; } + setShowPatterns(show: boolean): void { + this.showPatterns = show; + } + /** Set the hovered player's smallID for territory-fill brightening (0 = off). */ setHighlightOwner(ownerID: number): void { this.highlightOwner = ownerID; @@ -352,11 +367,19 @@ export class TerritoryPass { gl.uniform1f(this.uCharcoalAlpha, mo.charcoalAlpha); gl.uniform1ui(this.uHighlightOwner, this.highlightOwner); gl.uniform1f(this.uHighlightBrighten, mo.highlightFillBrighten); + gl.uniform1i( + this.uShowPatterns, + this.settings.passEnabled.territoryPatterns && this.showPatterns ? 1 : 0, + ); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.tileTex); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, this.paletteTex); + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D, this.patternMetaTex); + gl.activeTexture(gl.TEXTURE3); + gl.bindTexture(gl.TEXTURE_2D, this.patternDataTex); gl.bindVertexArray(this.vao); gl.drawArrays(gl.TRIANGLES, 0, 6); @@ -366,6 +389,6 @@ export class TerritoryPass { const gl = this.gl; gl.deleteProgram(this.program); gl.deleteVertexArray(this.vao); - // tileTex, trailTex, paletteTex owned by GPUResources / renderer + // tileTex, trailTex, paletteTex, patternMetaTex, patternDataTex owned by GPUResources / renderer } } diff --git a/src/client/render/gl/render-settings.json b/src/client/render/gl/render-settings.json index e51eaef0f..08d6234d0 100644 --- a/src/client/render/gl/render-settings.json +++ b/src/client/render/gl/render-settings.json @@ -2,6 +2,7 @@ "passEnabled": { "terrain": true, "mapOverlay": true, + "territoryPatterns": true, "structure": true, "unit": true, "name": true, diff --git a/src/client/render/gl/shaders/map-overlay/territory.frag.glsl b/src/client/render/gl/shaders/map-overlay/territory.frag.glsl index 91664f4b4..5443633b9 100644 --- a/src/client/render/gl/shaders/map-overlay/territory.frag.glsl +++ b/src/client/render/gl/shaders/map-overlay/territory.frag.glsl @@ -4,6 +4,9 @@ precision highp usampler2D; uniform usampler2D uTileTex; // R16UI — tile state per cell uniform sampler2D uPalette; // RGBA32F — player colors +uniform sampler2D uPatternMeta; // RGBA32F — 1D buffer, 1 px per owner. R=hasPattern, G=width, B=height, A=scale +uniform usampler2D uPatternData; // R8UI — 2D buffer, row per owner, bytes for bitmask +uniform int uShowPatterns; uniform vec2 uMapSize; uniform int uAltView; @@ -42,6 +45,29 @@ void main() { float u = (float(owner) + 0.5) / float(PALETTE_SIZE); vec4 color = texture(uPalette, vec2(u, 0.25)); + if (uShowPatterns == 1) { + vec4 meta = texelFetch(uPatternMeta, ivec2(int(owner), 0), 0); + if (meta.r > 0.0) { + int pWidth = int(meta.g); + int pHeight = int(meta.b); + int pScale = int(meta.a); + + int px = tc.x >> pScale; + int py = tc.y >> pScale; + int mx = ((px % pWidth) + pWidth) % pWidth; + int my = ((py % pHeight) + pHeight) % pHeight; + int bitIndex = my * pWidth + mx; + int byteIndex = bitIndex >> 3; + + uint patternByte = texelFetch(uPatternData, ivec2(byteIndex, int(owner)), 0).r; + bool isPrimary = (patternByte & (1u << uint(bitIndex & 7))) == 0u; + + if (!isPrimary) { + color = texture(uPalette, vec2(u, 0.75)); + } + } + } + // Hover highlight: brighten every tile owned by the hovered player. if (uHighlightOwner != 0u && owner == uHighlightOwner) { color.rgb = mix(color.rgb, vec3(1.0), uHighlightBrighten);