mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +00:00
Scale defense-post border + fill rendering to thousands of posts (#4181)
## Description
Scales the defense-post border effect so it works with **thousands** of
Defense Posts instead of silently capping at 64.
### Problem
The border "checkerboard" (drawn on a player's border tiles when a
same-owner Defense Post is within range) was computed per-pixel: for
every border fragment, the shader looped over a `uniform vec4
uDefensePosts[64]` array doing a distance test. Two issues:
- **Hard cap of 64** — posts beyond the first 64 were dropped, so their
checkerboard never appeared.
- **Wrong cost shape** — work was `border_tiles × posts`; every added
post made every border pixel slower.
### Solution: invert the loop into a coverage texture
New `DefenseCoveragePass` stamps one instanced circle per post into a
map-resolution `R8` coverage texture (`1.0` = tile is within range of a
**same-owner** post; the owner check samples `tileTex` at stamp time, so
enemy posts never light up your border). It's a single
`drawArraysInstanced` regardless of post count — the same instancing
pattern `UnitPass`/`StructurePass` already use. The border-stamp shader
now reads one texel of that texture instead of looping; the old uniform
array, the 64-cap, and the per-fragment scan are removed from
`border-compute`/`BorderStampPass`/`BorderScatterPass`.
### Incremental re-stamping (dirty-block grid)
Coverage depends on tile ownership, which drips every frame during
combat, so a full re-stamp every frame would be wasteful at high post
counts. Because a tile changing owner only changes *its own* coverage,
the pass tracks a grid of dirty **blocks** and re-stamps only the blocks
containing changed tiles, scissored to each block (`gl.scissor` confines
the clear + draw to the changed region). Post add/remove and full tile
uploads fall back to a whole-map stamp; so does a frame where most
blocks are dirty. Per-frame cost tracks *how much changed*, not *how
many posts exist*, and scattered fronts (e.g. opposite corners) become
independent small block draws.
### Territory-fill darkening
The coverage texture marks every same-owner in-range tile (interior
included, not just borders), so `TerritoryPass` now also samples it to
darken the territory **fill** around posts. New tunable
`mapOverlay.territoryDefenseDarken` (live-editable in the graphics debug
GUI alongside `defenseCheckerDarken`).
### Performance
Tested with ~1,000 posts blanketing a map — smooth, including on a
low-end (~10-year-old) Chromebook.
## Files
- **New:** `passes/DefenseCoveragePass.ts`,
`shaders/defense-coverage/defense-coverage.{vert,frag}.glsl`
- **Edited:** `Renderer.ts`, `BorderStampPass.ts`,
`BorderComputePass.ts`, `BorderScatterPass.ts`, `TerritoryPass.ts`,
`border-stamp.frag.glsl`, `border-compute.frag.glsl`,
`territory.frag.glsl`, `RenderSettings.ts`, `render-settings.json`,
`debug/Layout.ts`
## Notes
- No user-facing text (no `translateText`/`en.json` changes needed).
- No `src/core` changes — purely client rendering, so no simulation
tests; verified via `tsc`, ESLint, `build-prod`, and in-game.
This commit is contained in:
@@ -68,6 +68,7 @@ export interface RenderSettings {
|
||||
mapOverlay: {
|
||||
trailAlpha: number;
|
||||
defenseCheckerDarken: number;
|
||||
territoryDefenseDarken: number;
|
||||
staleNukeBase: number;
|
||||
staleNukeVariation: number;
|
||||
staleNukeAlpha: number;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"mapOverlay": {
|
||||
"trailAlpha": 0.588,
|
||||
"defenseCheckerDarken": 0.7,
|
||||
"territoryDefenseDarken": 0.85,
|
||||
"staleNukeBase": 0,
|
||||
"staleNukeVariation": 0.05,
|
||||
"staleNukeAlpha": 1,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user