diff --git a/src/client/render/gl/RenderSettings.ts b/src/client/render/gl/RenderSettings.ts index 227e5f502..f3387defe 100644 --- a/src/client/render/gl/RenderSettings.ts +++ b/src/client/render/gl/RenderSettings.ts @@ -68,6 +68,7 @@ export interface RenderSettings { mapOverlay: { trailAlpha: number; defenseCheckerDarken: number; + territoryDefenseDarken: number; staleNukeBase: number; staleNukeVariation: number; staleNukeAlpha: number; diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index f54e3660c..8ea85c237 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -33,6 +33,7 @@ import { BorderComputePass } from "./passes/BorderComputePass"; import { BorderStampPass } from "./passes/BorderStampPass"; import { CoordinateGridPass } from "./passes/CoordinateGridPass"; import { CrosshairPass } from "./passes/CrosshairPass"; +import { DefenseCoveragePass } from "./passes/DefenseCoveragePass"; import { FalloutBloomPass } from "./passes/FalloutBloomPass"; import { FalloutLightPass } from "./passes/FalloutLightPass"; import { FxPass } from "./passes/fx-pass"; @@ -101,6 +102,7 @@ export class GPURenderer { private trailPass: TrailPass; private borderStampPass: BorderStampPass; private borderPass: BorderComputePass; + private defenseCoveragePass: DefenseCoveragePass; private bloomPass: FalloutBloomPass; private pointLightPass: PointLightPass; private falloutLightPass: FalloutLightPass; @@ -308,6 +310,17 @@ export class GPURenderer { ); this.res.borderTex = this.borderPass.getBorderTex(); + // --- Defense coverage (needs tileTex) — per-tile "defended by same-owner + // post" flag, stamped one instanced circle per post. Replaces the old + // 64-cap uniform loop; consumed by BorderStampPass. --- + this.defenseCoveragePass = new DefenseCoveragePass( + gl, + mapW, + mapH, + this.res.tileTex, + this.settings, + ); + // --- Heat manager (needs tileTex, heatTexA/B) --- this.heatManager = new HeatManager( gl, @@ -334,10 +347,19 @@ export class GPURenderer { this.settings, ); // Route per-tile changes to the border pass so it can scatter-recompute - // just the affected tiles instead of rebuilding the whole map. - this.territoryPass.setBorderPatchConsumer((x, y) => - this.borderPass.patchTile(x, y), + // just the affected tiles instead of rebuilding the whole map. A tile + // changing owner can also flip its defense-coverage flag (same-owner test), + // so mark the coverage stale too — one coalesced re-stamp happens per frame. + this.territoryPass.setBorderPatchConsumer((x, y) => { + this.borderPass.patchTile(x, y); + this.defenseCoveragePass.markTileDirty(x, y); + }); + // Territory fill darkens on interior tiles defended by a same-owner post; + // borderTex lets the fill skip border tiles (those get the checkerboard). + this.territoryPass.setDefenseCoverageTex( + this.defenseCoveragePass.getCoverageTex(), ); + this.territoryPass.setBorderTex(this.res.borderTex); // --- Spawn overlay (needs tileTex) --- this.spawnOverlayPass = new SpawnOverlayPass( @@ -368,6 +390,9 @@ export class GPURenderer { this.res.borderTex, this.settings, ); + this.borderStampPass.setDefenseCoverageTex( + this.defenseCoveragePass.getCoverageTex(), + ); // --- Fallout bloom (needs tileTex, heatManager) --- this.bloomPass = new FalloutBloomPass( @@ -822,7 +847,7 @@ export class GPURenderer { }); } } - this.borderPass.updateDefensePosts(posts); + this.defenseCoveragePass.updateDefensePosts(posts); } applyDeadUnits(deadUnits: DeadUnitFx[]): void { @@ -1203,6 +1228,7 @@ export class GPURenderer { // pushed per-tile border patches via the wired `borderPatchConsumer`. if (this.territoryPass.flushTileTexture() === "full") { this.borderPass.markGlobalDirty(); + this.defenseCoveragePass.markDirty(); } this.trailPass.flushTexture(); this.heatManager.updateHeat(); @@ -1210,6 +1236,9 @@ export class GPURenderer { private computeTextures(): void { if (this.settings.passEnabled.borderCompute) this.borderPass.draw(); + // Re-stamp defense coverage if posts/territory changed (dirty-gated). + // Leaves the default framebuffer bound; renderFrame resets the viewport. + this.defenseCoveragePass.draw(); } private renderFrame(): void { @@ -1328,6 +1357,7 @@ export class GPURenderer { this.trailPass.dispose(); this.borderStampPass.dispose(); this.borderPass.dispose(); + this.defenseCoveragePass.dispose(); this.bloomPass.dispose(); this.pointLightPass.dispose(); this.falloutLightPass.dispose(); diff --git a/src/client/render/gl/debug/Layout.ts b/src/client/render/gl/debug/Layout.ts index 3e9074c2a..66ebef676 100644 --- a/src/client/render/gl/debug/Layout.ts +++ b/src/client/render/gl/debug/Layout.ts @@ -122,6 +122,7 @@ export function buildTree(s: RenderSettings, d: RenderSettings): DebugNode[] { folder("Map Overlay", [ slider(s.mapOverlay, "trailAlpha", d.mapOverlay, 0, 1, 0.01), slider(s.mapOverlay, "defenseCheckerDarken", d.mapOverlay, 0, 1, 0.01), + slider(s.mapOverlay, "territoryDefenseDarken", d.mapOverlay, 0, 1, 0.01), slider(s.mapOverlay, "staleNukeBase", d.mapOverlay, 0, 0.3, 0.005), slider(s.mapOverlay, "staleNukeVariation", d.mapOverlay, 0, 0.3, 0.005), slider(s.mapOverlay, "staleNukeAlpha", d.mapOverlay, 0, 1, 0.01), diff --git a/src/client/render/gl/passes/BorderComputePass.ts b/src/client/render/gl/passes/BorderComputePass.ts index 26130cc8f..414eb7ddf 100644 --- a/src/client/render/gl/passes/BorderComputePass.ts +++ b/src/client/render/gl/passes/BorderComputePass.ts @@ -5,7 +5,7 @@ * RGBA8 texture: * R = border type: 0 = interior, 0.5 = normal border, 1.0 = highlight border * G = unused (was ember intensity — moved to FalloutBloomPass/FalloutLightPass) - * B = defense proximity: 1.0 if border tile is within range of same-owner defense post + * B = unused (was defense proximity — now computed per-tile by DefenseCoveragePass) * * Both MapOverlayPass (daytime) and the night stamp overlay read this buffer * instead of independently computing neighbor checks. Border thickening is @@ -24,8 +24,6 @@ import { import { TILE_DEFINES } from "../utils/TileCodec"; import { BorderScatterPass } from "./BorderScatterPass"; -const MAX_DEFENSE_POSTS = 64; - /** Max player smallID supported by the relationship texture. */ const RELATION_TEX_SIZE = 1024; @@ -49,22 +47,15 @@ export class BorderComputePass { private uMapSize: WebGLUniformLocation; private uHighlightOwner: WebGLUniformLocation; private uHighlightThicken: WebGLUniformLocation; - private uDefensePosts: WebGLUniformLocation; - private uDefensePostCount: WebGLUniformLocation; - private uDefensePostRange: WebGLUniformLocation; private highlightOwner = 0; /** * True when something that affects ALL borders (highlight owner, relation - * matrix, defense posts) has changed since the last draw. Forces a full - * recompute next frame. Starts true so the first frame computes. + * matrix) has changed since the last draw. Forces a full recompute next + * frame. Starts true so the first frame computes. */ private globalDirty = true; - /** Packed defense post data: [x, y, ownerID, 0, x, y, ownerID, 0, ...] */ - private defensePostData = new Float32Array(MAX_DEFENSE_POSTS * 4); - private defensePostCount = 0; - /** Incremental per-tile recompute. Used between full recomputes. */ private scatter!: BorderScatterPass; @@ -83,7 +74,7 @@ export class BorderComputePass { this.program = createProgram( gl, fullscreenNoUvVertSrc, - shaderSrc(borderComputeFragSrc, { ...TILE_DEFINES, MAX_DEFENSE_POSTS }), + shaderSrc(borderComputeFragSrc, { ...TILE_DEFINES }), ); this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!; @@ -95,15 +86,6 @@ export class BorderComputePass { this.program, "uHighlightThicken", )!; - this.uDefensePosts = gl.getUniformLocation(this.program, "uDefensePosts")!; - this.uDefensePostCount = gl.getUniformLocation( - this.program, - "uDefensePostCount", - )!; - this.uDefensePostRange = gl.getUniformLocation( - this.program, - "uDefensePostRange", - )!; // Texture unit binding gl.useProgram(this.program); @@ -195,23 +177,6 @@ export class BorderComputePass { this.globalDirty = true; } - /** Update defense post positions for checkerboard proximity. */ - updateDefensePosts(posts: { x: number; y: number; ownerID: number }[]): void { - const count = Math.min(posts.length, MAX_DEFENSE_POSTS); - const data = this.defensePostData; - for (let i = 0; i < count; i++) { - const p = posts[i]; - const off = i * 4; - data[off] = p.x; - data[off + 1] = p.y; - data[off + 2] = p.ownerID; - data[off + 3] = 0; - } - this.defensePostCount = count; - this.scatter.setDefensePostData(data, count); - this.globalDirty = true; - } - /** * Force a full recompute next draw. Use this when tile state has been * replaced wholesale (initial load, seek) — individual `patchTile` calls @@ -238,8 +203,8 @@ export class BorderComputePass { /** * Update border flags for the current frame. Either a full recompute (when - * globalDirty is set by highlight/relation/defense-post changes) or a - * scatter of the per-tile patches queued via `patchTile`. + * globalDirty is set by highlight/relation changes) or a scatter of the + * per-tile patches queued via `patchTile`. * * Exit GL state: * - Full recompute path: `borderFbo` is still bound; viewport at map size. @@ -263,9 +228,6 @@ export class BorderComputePass { gl.uniform2f(this.uMapSize, this.mapW, this.mapH); gl.uniform1ui(this.uHighlightOwner, this.highlightOwner); gl.uniform1i(this.uHighlightThicken, Math.floor(mo.highlightThicken)); - gl.uniform4fv(this.uDefensePosts, this.defensePostData); - gl.uniform1i(this.uDefensePostCount, this.defensePostCount); - gl.uniform1f(this.uDefensePostRange, mo.defensePostRange); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this._tileTex); diff --git a/src/client/render/gl/passes/BorderScatterPass.ts b/src/client/render/gl/passes/BorderScatterPass.ts index 9e5d0429a..65851e125 100644 --- a/src/client/render/gl/passes/BorderScatterPass.ts +++ b/src/client/render/gl/passes/BorderScatterPass.ts @@ -26,7 +26,6 @@ import { TILE_DEFINES } from "../utils/TileCodec"; import borderComputeFragSrc from "../shaders/border-compute/border-compute.frag.glsl?raw"; import borderScatterVertSrc from "../shaders/border-compute/border-scatter.vert.glsl?raw"; -const MAX_DEFENSE_POSTS = 64; const FLOATS_PER_PATCH = 2; const INITIAL_CAPACITY = 4096; @@ -42,9 +41,6 @@ export class BorderScatterPass { private uMapSize: WebGLUniformLocation; private uHighlightOwner: WebGLUniformLocation; private uHighlightThicken: WebGLUniformLocation; - private uDefensePosts: WebGLUniformLocation; - private uDefensePostCount: WebGLUniformLocation; - private uDefensePostRange: WebGLUniformLocation; private fbo: WebGLFramebuffer; private vao: WebGLVertexArrayObject; @@ -52,8 +48,6 @@ export class BorderScatterPass { // Mirrored from BorderComputePass — set via setters when those change. private highlightOwner = 0; - private defensePostData = new Float32Array(MAX_DEFENSE_POSTS * 4); - private defensePostCount = 0; /** CPU-side patch buffer: [x, y, x, y, …]. */ private patchData: Float32Array; @@ -80,7 +74,7 @@ export class BorderScatterPass { this.program = createProgram( gl, borderScatterVertSrc, - shaderSrc(borderComputeFragSrc, { ...TILE_DEFINES, MAX_DEFENSE_POSTS }), + shaderSrc(borderComputeFragSrc, { ...TILE_DEFINES }), ); this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!; @@ -92,15 +86,6 @@ export class BorderScatterPass { this.program, "uHighlightThicken", )!; - this.uDefensePosts = gl.getUniformLocation(this.program, "uDefensePosts")!; - this.uDefensePostCount = gl.getUniformLocation( - this.program, - "uDefensePostCount", - )!; - this.uDefensePostRange = gl.getUniformLocation( - this.program, - "uDefensePostRange", - )!; gl.useProgram(this.program); gl.uniform1i(gl.getUniformLocation(this.program, "uTileTex"), 0); @@ -158,12 +143,6 @@ export class BorderScatterPass { this.highlightOwner = owner; } - setDefensePostData(data: Float32Array, count: number): void { - // Caller may mutate the source array; copy to keep ours stable. - this.defensePostData.set(data.subarray(0, MAX_DEFENSE_POSTS * 4)); - this.defensePostCount = count; - } - flush(): void { if (this.patchCount === 0) return; const gl = this.gl; @@ -190,9 +169,6 @@ export class BorderScatterPass { gl.uniform2f(this.uMapSize, this.mapW, this.mapH); gl.uniform1ui(this.uHighlightOwner, this.highlightOwner); gl.uniform1i(this.uHighlightThicken, Math.floor(mo.highlightThicken)); - gl.uniform4fv(this.uDefensePosts, this.defensePostData); - gl.uniform1i(this.uDefensePostCount, this.defensePostCount); - gl.uniform1f(this.uDefensePostRange, mo.defensePostRange); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.tileTex); diff --git a/src/client/render/gl/passes/BorderStampPass.ts b/src/client/render/gl/passes/BorderStampPass.ts index c3925e00f..8f1ce474c 100644 --- a/src/client/render/gl/passes/BorderStampPass.ts +++ b/src/client/render/gl/passes/BorderStampPass.ts @@ -35,6 +35,7 @@ export class BorderStampPass { private tileTex: WebGLTexture; private paletteTex: WebGLTexture; private borderTex: WebGLTexture; + private defenseCoverageTex: WebGLTexture | null = null; private affiliationTex: WebGLTexture | null = null; private altView = false; @@ -90,6 +91,7 @@ export class BorderStampPass { gl.uniform1i(gl.getUniformLocation(this.program, "uPalette"), 1); gl.uniform1i(gl.getUniformLocation(this.program, "uBorderTex"), 2); gl.uniform1i(gl.getUniformLocation(this.program, "uAffiliation"), 3); + gl.uniform1i(gl.getUniformLocation(this.program, "uDefenseCoverageTex"), 4); this.vao = createMapQuad(gl, mapW, mapH); } @@ -100,6 +102,9 @@ export class BorderStampPass { setAffiliationTex(tex: WebGLTexture): void { this.affiliationTex = tex; } + setDefenseCoverageTex(tex: WebGLTexture): void { + this.defenseCoverageTex = tex; + } /** Draw borders + defense checkerboard. Blending must be enabled. */ draw(cameraMatrix: Float32Array): void { @@ -137,6 +142,10 @@ export class BorderStampPass { gl.activeTexture(gl.TEXTURE3); gl.bindTexture(gl.TEXTURE_2D, this.affiliationTex); } + if (this.defenseCoverageTex) { + gl.activeTexture(gl.TEXTURE4); + gl.bindTexture(gl.TEXTURE_2D, this.defenseCoverageTex); + } gl.bindVertexArray(this.vao); gl.drawArrays(gl.TRIANGLES, 0, 6); diff --git a/src/client/render/gl/passes/DefenseCoveragePass.ts b/src/client/render/gl/passes/DefenseCoveragePass.ts new file mode 100644 index 000000000..f7593dbef --- /dev/null +++ b/src/client/render/gl/passes/DefenseCoveragePass.ts @@ -0,0 +1,280 @@ +/** + * DefenseCoveragePass — per-tile "is this tile defended by a same-owner Defense + * Post?" flag, computed by stamping one instanced circle per post. + * + * Replaces the old per-fragment scan (border-compute looped over a uniform array + * of up to 64 posts for every border tile). Here we invert the loop: each post + * draws a filled circle of its range into a map-resolution R8 texture, writing + * 1.0 on tiles it owns and within range. Cost is O(posts × circle area) with no + * cap on post count, and it's a single instanced draw call regardless of how + * many posts exist — the same pattern UnitPass/StructurePass already use. + * + * BorderStampPass samples the resulting `coverageTex` (one texel per border + * tile) instead of the old uniform loop. The texture marks every same-owner + * in-range tile, interior included — so a future PR can darken the territory + * fill by sampling the same texture in TerritoryPass. + * + * The result depends on tile ownership (the same-owner test), so coverage must + * be re-stamped whenever posts OR territory change. Territory drips every frame + * during combat, so a full map re-stamp every frame would be wasteful at high + * post counts. Instead we track a grid of dirty BLOCKs: a tile changing owner + * only changes its own coverage, so we recompute just the blocks containing + * changed tiles, scissored to each block (clear the block, redraw the posts — + * the scissor confines the GPU work to the changed region). A post add/remove + * or full tile upload sets `fullDirty` for one whole-map stamp; if too many + * blocks are dirty we fall back to a full stamp too. + * + * Exit GL state: default framebuffer bound; viewport left at map size; scissor + * test disabled. The caller (Renderer.renderFrame) rebinds framebuffer + + * viewport before screen draws. + */ + +import { DynamicInstanceBuffer } from "../DynamicBuffer"; +import type { RenderSettings } from "../RenderSettings"; +import coverageFragSrc from "../shaders/defense-coverage/defense-coverage.frag.glsl?raw"; +import coverageVertSrc from "../shaders/defense-coverage/defense-coverage.vert.glsl?raw"; +import { createProgram, createTexture2D, shaderSrc } from "../utils/GlUtils"; +import { TILE_DEFINES } from "../utils/TileCodec"; + +/** Per-instance data (3 floats): tileX, tileY, ownerID. */ +const FLOATS_PER_INSTANCE = 3; + +/** + * Tile block size for incremental scissored re-stamping. ~2× the post diameter + * (range ≈ 30, so circles ≈ 60 wide): small enough to confine work to the + * changed region, large enough to keep the per-block draw-call count low. + */ +const BLOCK = 128; + +export class DefenseCoveragePass { + private gl: WebGL2RenderingContext; + private settings: RenderSettings; + private mapW: number; + private mapH: number; + private tileTex: WebGLTexture; + + private program: WebGLProgram; + private uMapSize: WebGLUniformLocation; + private uRange: WebGLUniformLocation; + + private coverageTex: WebGLTexture; + private fbo: WebGLFramebuffer; + + private quadBuf: WebGLBuffer; + private vao: WebGLVertexArrayObject; + private instanceBuf: DynamicInstanceBuffer; + private count = 0; + + // --- Dirty tracking (block grid) --- + private blocksX: number; + private blocksY: number; + /** Re-stamp the whole map next draw. Starts true so the first frame computes. */ + private fullDirty = true; + /** Per-block dirty flag (0/1), indexed by blockY * blocksX + blockX. */ + private dirtyBlock: Uint8Array; + /** Indices of currently-dirty blocks (to iterate + reset without scanning). */ + private dirtyList: number[] = []; + /** Above this many dirty blocks, a single full stamp is cheaper. */ + private fullFallback: number; + + constructor( + gl: WebGL2RenderingContext, + mapW: number, + mapH: number, + tileTex: WebGLTexture, + settings: RenderSettings, + ) { + this.gl = gl; + this.settings = settings; + this.mapW = mapW; + this.mapH = mapH; + this.tileTex = tileTex; + + this.blocksX = Math.ceil(mapW / BLOCK); + this.blocksY = Math.ceil(mapH / BLOCK); + this.dirtyBlock = new Uint8Array(this.blocksX * this.blocksY); + // Past ~half the blocks, one full stamp beats many scissored block draws. + this.fullFallback = Math.floor((this.blocksX * this.blocksY) / 2); + + this.program = createProgram( + gl, + coverageVertSrc, + shaderSrc(coverageFragSrc, { OWNER_MASK: TILE_DEFINES.OWNER_MASK }), + ); + this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!; + this.uRange = gl.getUniformLocation(this.program, "uRange")!; + + gl.useProgram(this.program); + gl.uniform1i(gl.getUniformLocation(this.program, "uTileTex"), 0); + + // --- R8 coverage texture at tile resolution + its FBO --- + this.coverageTex = createTexture2D(gl, { + width: mapW, + height: mapH, + internalFormat: gl.R8, + format: gl.RED, + type: gl.UNSIGNED_BYTE, + data: null, + filter: gl.NEAREST, + }); + this.fbo = gl.createFramebuffer()!; + gl.bindFramebuffer(gl.FRAMEBUFFER, this.fbo); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + this.coverageTex, + 0, + ); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + // --- Shared unit quad [0,1]² --- + this.quadBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]), + gl.STATIC_DRAW, + ); + + // --- Per-post instance buffer + VAO --- + const instGlBuf = gl.createBuffer()!; + this.instanceBuf = new DynamicInstanceBuffer( + gl, + instGlBuf, + 256, + FLOATS_PER_INSTANCE, + ); + this.vao = gl.createVertexArray()!; + gl.bindVertexArray(this.vao); + gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + gl.bindBuffer(gl.ARRAY_BUFFER, instGlBuf); + gl.enableVertexAttribArray(1); + gl.vertexAttribPointer(1, 3, gl.FLOAT, false, FLOATS_PER_INSTANCE * 4, 0); + gl.vertexAttribDivisor(1, 1); + gl.bindVertexArray(null); + } + + /** Replace the set of defense posts. No cap. */ + updateDefensePosts(posts: { x: number; y: number; ownerID: number }[]): void { + this.count = posts.length; + this.instanceBuf.ensureCapacity(posts.length); + const f = this.instanceBuf.float32; + for (let i = 0; i < posts.length; i++) { + const p = posts[i]; + const off = i * FLOATS_PER_INSTANCE; + f[off] = p.x; + f[off + 1] = p.y; + f[off + 2] = p.ownerID; + } + if (posts.length > 0) { + const gl = this.gl; + gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf.buffer); + gl.bufferSubData( + gl.ARRAY_BUFFER, + 0, + this.instanceBuf.float32, + 0, + posts.length * FLOATS_PER_INSTANCE, + ); + } + // A post appearing/disappearing affects its whole circle (possibly several + // blocks); post-set changes are rare, so just re-stamp the whole map. + this.fullDirty = true; + } + + /** + * Mark the block containing tile (x, y) stale. Call when a tile changed owner + * — the same-owner test means only that tile's own coverage can flip, so just + * its block needs recomputing. Coalesced: the block is re-stamped once in the + * next draw() regardless of how many of its tiles changed. + */ + markTileDirty(x: number, y: number): void { + const bx = (x / BLOCK) | 0; + const by = (y / BLOCK) | 0; + const b = by * this.blocksX + bx; + if (this.dirtyBlock[b] === 0) { + this.dirtyBlock[b] = 1; + this.dirtyList.push(b); + } + } + + /** Force a whole-map re-stamp next draw (full tile upload / seek). */ + markDirty(): void { + this.fullDirty = true; + } + + /** The R8 coverage texture (1.0 = tile is defended by a same-owner post). */ + getCoverageTex(): WebGLTexture { + return this.coverageTex; + } + + /** + * Re-stamp coverage if anything changed. Either a whole-map stamp (fullDirty, + * or too many blocks dirty) or a scissored clear+stamp per dirty block. + * + * Exit GL state: default framebuffer bound; scissor test disabled; viewport + * left at map size (caller resets before screen draws). + */ + draw(): void { + if (!this.fullDirty && this.dirtyList.length === 0) return; + + const gl = this.gl; + gl.bindFramebuffer(gl.FRAMEBUFFER, this.fbo); + gl.viewport(0, 0, this.mapW, this.mapH); + gl.disable(gl.BLEND); + gl.clearColor(0, 0, 0, 0); + + // Shared stamp state (uniforms/textures/VAO don't change between blocks). + gl.useProgram(this.program); + gl.uniform2f(this.uMapSize, this.mapW, this.mapH); + gl.uniform1f(this.uRange, this.settings.mapOverlay.defensePostRange); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.tileTex); + gl.bindVertexArray(this.vao); + + if (this.fullDirty || this.dirtyList.length > this.fullFallback) { + // Whole-map stamp. + gl.disable(gl.SCISSOR_TEST); + gl.clear(gl.COLOR_BUFFER_BIT); + if (this.count > 0) + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.count); + } else { + // Per-block scissored stamp — confines clear + draw to changed regions. + gl.enable(gl.SCISSOR_TEST); + for (const b of this.dirtyList) { + const bx = (b % this.blocksX) * BLOCK; + const by = ((b / this.blocksX) | 0) * BLOCK; + // Tile coords map 1:1 to FBO pixels (no Y flip), so pass the block rect + // straight to scissor, clamping the right/bottom edge blocks to bounds. + const bw = Math.min(BLOCK, this.mapW - bx); + const bh = Math.min(BLOCK, this.mapH - by); + gl.scissor(bx, by, bw, bh); + gl.clear(gl.COLOR_BUFFER_BIT); + if (this.count > 0) { + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.count); + } + } + gl.disable(gl.SCISSOR_TEST); + } + + // Reset dirty state. + for (const b of this.dirtyList) this.dirtyBlock[b] = 0; + this.dirtyList.length = 0; + this.fullDirty = false; + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + gl.deleteTexture(this.coverageTex); + gl.deleteFramebuffer(this.fbo); + gl.deleteBuffer(this.quadBuf); + gl.deleteVertexArray(this.vao); + this.instanceBuf.dispose(); + } +} diff --git a/src/client/render/gl/passes/TerritoryPass.ts b/src/client/render/gl/passes/TerritoryPass.ts index 07c75a533..a94a897e6 100644 --- a/src/client/render/gl/passes/TerritoryPass.ts +++ b/src/client/render/gl/passes/TerritoryPass.ts @@ -40,6 +40,7 @@ export class TerritoryPass { private uHighlightBrighten: WebGLUniformLocation; private uShowPatterns: WebGLUniformLocation; private uIsTeamMode: WebGLUniformLocation; + private uDefenseDarken: WebGLUniformLocation; private highlightOwner = 0; private isTeamMode = false; @@ -51,6 +52,8 @@ export class TerritoryPass { private skinAtlasTex: WebGLTexture; private skinLayerTex: WebGLTexture; private skinAnchorTex: WebGLTexture; + private defenseCoverageTex: WebGLTexture | null = null; + private borderTex: WebGLTexture | null = null; private altView = false; private showPatterns = true; @@ -158,6 +161,10 @@ export class TerritoryPass { )!; this.uShowPatterns = gl.getUniformLocation(this.program, "uShowPatterns")!; this.uIsTeamMode = gl.getUniformLocation(this.program, "uIsTeamMode")!; + this.uDefenseDarken = gl.getUniformLocation( + this.program, + "uDefenseDarken", + )!; gl.useProgram(this.program); gl.uniform1i(gl.getUniformLocation(this.program, "uTileTex"), 0); @@ -167,6 +174,8 @@ export class TerritoryPass { gl.uniform1i(gl.getUniformLocation(this.program, "uSkinAtlas"), 4); gl.uniform1i(gl.getUniformLocation(this.program, "uSkinLayer"), 5); gl.uniform1i(gl.getUniformLocation(this.program, "uSkinAnchor"), 6); + gl.uniform1i(gl.getUniformLocation(this.program, "uDefenseCoverageTex"), 7); + gl.uniform1i(gl.getUniformLocation(this.program, "uBorderTex"), 8); this.vao = createMapQuad(gl, mapW, mapH); @@ -411,6 +420,16 @@ export class TerritoryPass { this.highlightOwner = ownerID; } + /** Defense-coverage texture (R8) — darkens the fill on defended tiles. */ + setDefenseCoverageTex(tex: WebGLTexture): void { + this.defenseCoverageTex = tex; + } + + /** Border flags (RGBA8) — used to skip the defense darken on border tiles. */ + setBorderTex(tex: WebGLTexture): void { + this.borderTex = tex; + } + /** Draw territory fill + stale-nuke ground. Blending must be enabled by caller. */ draw(cameraMatrix: Float32Array): void { this.flushTileTexture(); @@ -438,6 +457,7 @@ export class TerritoryPass { this.settings.passEnabled.territoryPatterns && this.showPatterns ? 1 : 0, ); gl.uniform1i(this.uIsTeamMode, this.isTeamMode ? 1 : 0); + gl.uniform1f(this.uDefenseDarken, mo.territoryDefenseDarken); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.tileTex); @@ -453,6 +473,14 @@ export class TerritoryPass { gl.bindTexture(gl.TEXTURE_2D, this.skinLayerTex); gl.activeTexture(gl.TEXTURE6); gl.bindTexture(gl.TEXTURE_2D, this.skinAnchorTex); + if (this.defenseCoverageTex) { + gl.activeTexture(gl.TEXTURE7); + gl.bindTexture(gl.TEXTURE_2D, this.defenseCoverageTex); + } + if (this.borderTex) { + gl.activeTexture(gl.TEXTURE8); + gl.bindTexture(gl.TEXTURE_2D, this.borderTex); + } gl.bindVertexArray(this.vao); gl.drawArrays(gl.TRIANGLES, 0, 6); diff --git a/src/client/render/gl/render-settings.json b/src/client/render/gl/render-settings.json index cc98d49b1..6a50b7087 100644 --- a/src/client/render/gl/render-settings.json +++ b/src/client/render/gl/render-settings.json @@ -66,6 +66,7 @@ "mapOverlay": { "trailAlpha": 0.588, "defenseCheckerDarken": 0.7, + "territoryDefenseDarken": 0.85, "staleNukeBase": 0, "staleNukeVariation": 0.05, "staleNukeAlpha": 1, diff --git a/src/client/render/gl/shaders/border-compute/border-compute.frag.glsl b/src/client/render/gl/shaders/border-compute/border-compute.frag.glsl index 8d060f9a3..14677d14c 100644 --- a/src/client/render/gl/shaders/border-compute/border-compute.frag.glsl +++ b/src/client/render/gl/shaders/border-compute/border-compute.frag.glsl @@ -8,11 +8,6 @@ uniform vec2 uMapSize; uniform uint uHighlightOwner; uniform int uHighlightThicken; // Chebyshev radius for highlight expansion -// Defense post proximity — (x, y, ownerID, _) per post -uniform vec4 uDefensePosts[MAX_DEFENSE_POSTS]; -uniform int uDefensePostCount; -uniform float uDefensePostRange; - out vec4 fragColor; uint getOwner(ivec2 c) { @@ -82,26 +77,10 @@ void main() { } } - // --- Defense post proximity --- - float defenseFlag = 0.0; - if (borderType > 0.0 && owner != 0u) { - float rangeSq = uDefensePostRange * uDefensePostRange; - for (int i = 0; i < MAX_DEFENSE_POSTS; i++) { - if (i >= uDefensePostCount) break; - vec4 dp = uDefensePosts[i]; - if (uint(dp.z) != owner) continue; - float dx = float(tc.x) - dp.x; - float dy = float(tc.y) - dp.y; - if (dx * dx + dy * dy <= rangeSq) { - defenseFlag = 1.0; - break; - } - } - } - // A = relationship: 0.0=neutral, 0.5=friendly, 1.0=embargo float relation = float(maxRel) * 0.5; // G channel is unused (formerly emberIntensity; ember is now computed in - // FalloutBloomPass and FalloutLightPass). - fragColor = vec4(borderType, 0.0, defenseFlag, relation); + // FalloutBloomPass and FalloutLightPass). B channel is unused (defense post + // proximity is now computed per-tile by DefenseCoveragePass). + fragColor = vec4(borderType, 0.0, 0.0, relation); } diff --git a/src/client/render/gl/shaders/day-night/border-stamp.frag.glsl b/src/client/render/gl/shaders/day-night/border-stamp.frag.glsl index 62873a97d..2bf641dbb 100644 --- a/src/client/render/gl/shaders/day-night/border-stamp.frag.glsl +++ b/src/client/render/gl/shaders/day-night/border-stamp.frag.glsl @@ -5,6 +5,7 @@ precision highp usampler2D; uniform usampler2D uTileTex; uniform sampler2D uPalette; uniform sampler2D uBorderTex; // RGBA8 — border flags from BorderComputePass +uniform sampler2D uDefenseCoverageTex; // R8 — 1.0 = defended by same-owner post uniform sampler2D uAffiliation; // 256×2 RGBA8 — affiliation colors (row 0 = border) uniform vec2 uMapSize; uniform int uAltView; @@ -28,7 +29,7 @@ void main() { // Read pre-computed border flags from BorderComputePass vec4 borderData = texelFetch(uBorderTex, tc, 0); float borderType = borderData.r; // 0=interior, ~0.5=normal, ~1.0=highlight - bool defense = borderData.b > 0.5; // defense post proximity + bool defense = texelFetch(uDefenseCoverageTex, tc, 0).r > 0.5; // same-owner defense post nearby float relation = borderData.a; // 0.0=neutral, ~0.5=friendly, ~1.0=embargo bool isBorder = borderType > 0.25; diff --git a/src/client/render/gl/shaders/defense-coverage/defense-coverage.frag.glsl b/src/client/render/gl/shaders/defense-coverage/defense-coverage.frag.glsl new file mode 100644 index 000000000..22a27cbe8 --- /dev/null +++ b/src/client/render/gl/shaders/defense-coverage/defense-coverage.frag.glsl @@ -0,0 +1,34 @@ +#version 300 es +precision highp float; +precision highp usampler2D; + +// Writes 1.0 into the coverage texture for every tile within `uRange` of the +// post being stamped AND owned by that post's owner. The texture is cleared to +// 0 first, so a tile no post touches stays 0. Overlapping circles from the same +// owner compose as a boolean union (1.0 over 1.0 is still 1.0 — no blending); +// circles from other owners discard at this tile and leave it untouched. + +uniform usampler2D uTileTex; // R16UI — tile state per cell +uniform vec2 uMapSize; +uniform float uRange; + +flat in vec2 vPostCenter; +flat in float vOwner; + +out vec4 fragColor; + +void main() { + ivec2 tc = ivec2(gl_FragCoord.xy); + if (tc.x >= int(uMapSize.x) || tc.y >= int(uMapSize.y)) discard; + + // Circle test in tile-integer space (matches the old per-fragment loop). + float dx = float(tc.x) - vPostCenter.x; + float dy = float(tc.y) - vPostCenter.y; + if (dx * dx + dy * dy > uRange * uRange) discard; + + // Same-owner test: only the tile's own owner's posts defend it. + uint owner = texelFetch(uTileTex, tc, 0).r & uint(OWNER_MASK); + if (owner != uint(vOwner)) discard; + + fragColor = vec4(1.0); +} diff --git a/src/client/render/gl/shaders/defense-coverage/defense-coverage.vert.glsl b/src/client/render/gl/shaders/defense-coverage/defense-coverage.vert.glsl new file mode 100644 index 000000000..27102e596 --- /dev/null +++ b/src/client/render/gl/shaders/defense-coverage/defense-coverage.vert.glsl @@ -0,0 +1,29 @@ +#version 300 es +precision highp float; + +// One instanced quad per defense post. The quad is a box centered on the post, +// sized to cover the post's range; the fragment shader trims it to a circle and +// filters by tile owner. See DefenseCoveragePass. + +layout(location = 0) in vec2 aCorner; // unit quad corner, [0,1]² +layout(location = 1) in vec3 aPost; // (tileX, tileY, ownerID) + +uniform vec2 uMapSize; +uniform float uRange; + +flat out vec2 vPostCenter; +flat out float vOwner; + +void main() { + vPostCenter = aPost.xy; + vOwner = aPost.z; + + // Box spanning [center - range, center + range] in tile coords, plus a + // 1-tile margin so the boundary tiles at exactly `range` are rasterized + // (their pixel centers sit just past the un-padded edge). + vec2 tilePos = aPost.xy + (aCorner * 2.0 - 1.0) * (uRange + 1.0); + + // Tile-resolution FBO (viewport = map size), so map straight to clip space. + vec2 ndc = (tilePos / uMapSize) * 2.0 - 1.0; + gl_Position = vec4(ndc, 0.0, 1.0); +} 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 83ecad40d..5e84fbc48 100644 --- a/src/client/render/gl/shaders/map-overlay/territory.frag.glsl +++ b/src/client/render/gl/shaders/map-overlay/territory.frag.glsl @@ -22,6 +22,9 @@ uniform float uStaleNukeAlpha; uniform vec3 uStaleNukeColor; uniform uint uHighlightOwner; // 0 = no highlight; otherwise smallID of hovered owner uniform float uHighlightBrighten; // mix amount toward white for highlighted tiles +uniform sampler2D uDefenseCoverageTex; // R8 — 1.0 = tile defended by same-owner post +uniform float uDefenseDarken; // multiplier applied to fill on defended tiles +uniform sampler2D uBorderTex; // RGBA8 — border flags; R > 0.25 = border tile in vec2 vWorldPos; out vec4 fragColor; @@ -108,5 +111,15 @@ void main() { color.rgb = clamp(mix(vec3(luma), color.rgb, 1.6), 0.0, 1.0); } + // Defense bonus: darken the fill on interior tiles defended by a same-owner + // post. Border tiles are skipped — they get the checkerboard overlay from + // BorderStampPass instead. Coverage is tested first so the (rarer) defended + // tiles are the only ones that pay for the extra border fetch (&& short- + // circuits in GLSL ES 3.00; texelFetch is derivative-free so this is safe). + if (texelFetch(uDefenseCoverageTex, tc, 0).r > 0.5 && + texelFetch(uBorderTex, tc, 0).r <= 0.25) { + color.rgb *= uDefenseDarken; + } + fragColor = color; }