Files
OpenFrontIO/src/client/render/gl/passes/BorderComputePass.ts
T
Evan fe6581e3fe update webgl nuke effects (#3984)
## Description:

Reworks the visual look of nuked tiles to read uniformly green (no more
brown/black bleed-through), and moves the ember "particle" effect out of
the border passes — where it lived as a storage-sharing hack — into the
fallout system where it belongs.

## What changed visually

- **Fresh fallout**: bright uniform bloom with a hint of flickering
green particles dampened on fresh tiles, ramping up as heat decays
(`particleFreshScale` controls the fresh-tile dampening).
- **Stale fallout**: dark-green ground (was near-black charcoal), with
full-strength flickering particles in dark-green ↔ light-green.
- **Particles**: per-tile flicker is now de-synced (each tile pulses at
its own rate, 0.4×–1.6× base speed) so the eye can't lock onto a global
rhythm.
- **No more brown/black pixels** in fallout zones. Two root causes were
fixed:
- The territory pass now renders stale-nuke ground for **all** fallout
tiles, not just unowned ones — so an owned player's color can't show
through where the bloom is dim/transparent.
- The ember stamp (which fully replaced tile color with orange) is gone;
particle render is now additive and color-tuned green.

## Architecture cleanup

The ember effect was conceptually fallout-domain, but lived in
`BorderComputePass` (writing intensity into `borderTex.g`) and
`BorderStampPass` (stamping orange dots), just because the border pass
already had an RGBA8 texture with a free G channel. Two consumers read
from it (`BorderStampPass`, `FalloutLightPass`), and the per-tile
flicker math used no border data at all.

This PR relocates the math inline into the two passes that actually need
it (`FalloutBloomPass.extract.frag.glsl` and
`FalloutLightPass.fallout-light.frag.glsl`), drops the ember code from
both border passes, and renames `mapOverlay.ember*` →
`falloutBloom.particle*` so the settings live with their pass.

Side benefits:
- **Animation correctness**: the old setup only updated ember intensity
when `BorderComputePass`'s dirty flag flipped (highlight change,
relations update, etc.), so the supposed flicker was actually a frozen
snapshot between border events. The new inline path runs every frame as
intended.
- **Slightly cheaper per-frame compute**: removed a per-dirty-event
full-map writeback to `borderTex.g`; added a few cheap ALU ops (1 sin +
2 hashes) per fallout tile in shaders that were already running. Same
texture memory.

## Other small changes

- Renamed `mapOverlay.charcoal*` → `mapOverlay.staleNuke*` (charcoal was
a misnomer now that the ground is green).
- Added `staleNukeR/G/B` for the ground color (was hardcoded grey).
- `intensityHot` bumped 0.6 → 1.8 for a brighter fresh-nuke glow.
- Raised `railroad.railMinZoom` 2 → 4 and `railDetailZoom` 4 → 6 so
rails pop in later (separate small commit).

<img width="354" height="371" alt="Screenshot 2026-05-22 at 10 37 34 AM"
src="https://github.com/user-attachments/assets/03b46c45-c617-41b3-b3e4-9934f064bfe1"
/>
<img width="335" height="358" alt="Screenshot 2026-05-22 at 10 37 43 AM"
src="https://github.com/user-attachments/assets/af370b19-8f22-4694-9859-1ad52aa755a7"
/>
<img width="651" height="613" alt="Screenshot 2026-05-22 at 10 38 09 AM"
src="https://github.com/user-attachments/assets/e06e5101-8529-49f6-b29a-ce0563eb52d6"
/>


## 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:

evan
2026-05-22 11:08:26 +01:00

245 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* BorderComputePass — tile-resolution pass that computes per-tile border flags.
*
* Runs a fullscreen quad at tile resolution (mapW × mapH) and writes to an
* 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
*
* Both MapOverlayPass (daytime) and the night stamp overlay read this buffer
* instead of independently computing neighbor checks. Border thickening is
* computed once here via an N-tile Chebyshev radius expansion.
*/
import type { RenderSettings } from "../RenderSettings";
import borderComputeFragSrc from "../shaders/border-compute/border-compute.frag.glsl?raw";
import fullscreenNoUvVertSrc from "../shaders/shared/fullscreen-no-uv.vert.glsl?raw";
import {
createFullscreenQuad,
createProgram,
createTexture2D,
shaderSrc,
} from "../utils/GlUtils";
import { TILE_DEFINES } from "../utils/TileCodec";
const MAX_DEFENSE_POSTS = 64;
/** Max player smallID supported by the relationship texture. */
const RELATION_TEX_SIZE = 1024;
// ---------------------------------------------------------------------------
// BorderComputePass
// ---------------------------------------------------------------------------
export class BorderComputePass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
private program: WebGLProgram;
private vao: WebGLVertexArrayObject;
private borderTex: WebGLTexture;
private borderFbo: WebGLFramebuffer;
private mapW: number;
private mapH: number;
private relationTex: WebGLTexture;
private uMapSize: WebGLUniformLocation;
private uHighlightOwner: WebGLUniformLocation;
private uHighlightThicken: WebGLUniformLocation;
private uDefensePosts: WebGLUniformLocation;
private uDefensePostCount: WebGLUniformLocation;
private uDefensePostRange: WebGLUniformLocation;
private highlightOwner = 0;
/** True when any input has changed since last draw. Starts true so first frame computes. */
private dirty = true;
/** Packed defense post data: [x, y, ownerID, 0, x, y, ownerID, 0, ...] */
private defensePostData = new Float32Array(MAX_DEFENSE_POSTS * 4);
private defensePostCount = 0;
constructor(
gl: WebGL2RenderingContext,
mapW: number,
mapH: number,
tileTex: WebGLTexture,
settings: RenderSettings,
) {
this.gl = gl;
this.settings = settings;
this.mapW = mapW;
this.mapH = mapH;
this.program = createProgram(
gl,
fullscreenNoUvVertSrc,
shaderSrc(borderComputeFragSrc, { ...TILE_DEFINES, MAX_DEFENSE_POSTS }),
);
this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!;
this.uHighlightOwner = gl.getUniformLocation(
this.program,
"uHighlightOwner",
)!;
this.uHighlightThicken = gl.getUniformLocation(
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);
gl.uniform1i(gl.getUniformLocation(this.program, "uTileTex"), 0);
gl.uniform1i(gl.getUniformLocation(this.program, "uRelationTex"), 1);
// --- Relationship texture (R8UI, RELATION_TEX_SIZE × RELATION_TEX_SIZE) ---
this.relationTex = createTexture2D(gl, {
width: RELATION_TEX_SIZE,
height: RELATION_TEX_SIZE,
internalFormat: gl.R8UI,
format: gl.RED_INTEGER,
type: gl.UNSIGNED_BYTE,
data: null,
filter: gl.NEAREST,
});
// --- RGBA8 border buffer at tile resolution ---
// R = border type, G = unused, B = defense proximity flag
this.borderTex = createTexture2D(gl, {
width: mapW,
height: mapH,
internalFormat: gl.RGBA8,
format: gl.RGBA,
type: gl.UNSIGNED_BYTE,
data: null,
filter: gl.NEAREST,
});
// FBO
this.borderFbo = gl.createFramebuffer()!;
gl.bindFramebuffer(gl.FRAMEBUFFER, this.borderFbo);
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
this.borderTex,
0,
);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
// Fullscreen quad VAO [0,1]
this.vao = createFullscreenQuad(gl);
// Store tileTex reference for binding
this._tileTex = tileTex;
}
private _tileTex: WebGLTexture;
/** Set the highlighted player's ownerID (0 = no highlight). */
setHighlightOwner(ownerID: number): void {
if (ownerID === this.highlightOwner) return;
this.highlightOwner = ownerID;
this.dirty = true;
}
/**
* Upload a relationship matrix (R8UI, size × size).
* Values: 0 = neutral, 1 = friendly, 2 = embargo.
* Indexed by [ownerA, ownerB]. Size must be ≤ RELATION_TEX_SIZE.
*/
updateRelations(data: Uint8Array, size: number): void {
const gl = this.gl;
const s = Math.min(size, RELATION_TEX_SIZE);
gl.bindTexture(gl.TEXTURE_2D, this.relationTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
s,
s,
gl.RED_INTEGER,
gl.UNSIGNED_BYTE,
data,
);
this.dirty = 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.dirty = true;
}
/** Notify that the tile texture has been updated (ownership may have changed). */
notifyTilesChanged(): void {
this.dirty = true;
}
/** The border buffer texture (RG8, tile resolution). */
getBorderTex(): WebGLTexture {
return this.borderTex;
}
/**
* Compute border flags for the current frame. Call before MapOverlayPass and stamp overlay.
* Leaves the GL state with its own FBO bound — caller must restore FBO and viewport.
*/
draw(): void {
if (!this.dirty) return;
this.dirty = false;
const gl = this.gl;
const mo = this.settings.mapOverlay;
gl.bindFramebuffer(gl.FRAMEBUFFER, this.borderFbo);
gl.viewport(0, 0, this.mapW, this.mapH);
gl.disable(gl.BLEND);
gl.useProgram(this.program);
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);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.relationTex);
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
gl.deleteTexture(this.borderTex);
gl.deleteTexture(this.relationTex);
gl.deleteFramebuffer(this.borderFbo);
}
}