Move relationship calculation (for borders) into the shader.

- GPU Relation Calculations: Moved border diplomatic relation logic from CPU buffer to WebGL shader, eliminating per-tile CPU computation
- Relation Matrix: Converted 1D relation array to 2D owner×other matrix for O(1) GPU lookups
- Palette Refresh: Batched palette refreshes to single call per update cycle

WebGL Shader Updates
- Added u_viewerId uniform and bitmask relation helper functions (isFriendly(), isEmbargo(), isSelf())
- Enhanced border detection with per-neighbor relation evaluation
Removed CPU-side relation state management

Files: TerritoryLayer.ts, TerritoryRenderers.ts, TerritoryWebGLRenderer.ts (+102/-39 lines)
This commit is contained in:
scamiv
2025-12-03 20:01:43 +01:00
parent f1fe5549dc
commit 9ed794264d
3 changed files with 102 additions and 39 deletions
+7 -3
View File
@@ -106,9 +106,7 @@ export class TerritoryLayer implements Layer {
const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : [];
const playerUpdates =
updates !== null ? updates[GameUpdateType.Player] : [];
if (playerUpdates.length > 0) {
this.territoryRenderer?.refreshPalette();
}
let needsRelationRefresh = playerUpdates.length > 0;
unitUpdates.forEach((update) => {
if (update.unitType === UnitType.DefensePost) {
// Only update borders if the defense post is not under construction
@@ -138,6 +136,7 @@ export class TerritoryLayer implements Layer {
const territory = this.game.playerBySmallID(update.betrayedID);
if (territory && territory instanceof PlayerView) {
this.redrawBorder(territory);
needsRelationRefresh = true;
}
});
@@ -154,6 +153,7 @@ export class TerritoryLayer implements Layer {
const territory = this.game.playerBySmallID(territoryId);
if (territory && territory instanceof PlayerView) {
this.redrawBorder(territory);
needsRelationRefresh = true;
}
}
});
@@ -168,9 +168,13 @@ export class TerritoryLayer implements Layer {
embargoed.id() === myPlayer?.id()
) {
this.redrawBorder(player, embargoed);
needsRelationRefresh = true;
}
});
}
if (needsRelationRefresh) {
this.territoryRenderer?.refreshPalette();
}
const focusedPlayer = this.game.focusedPlayer();
if (focusedPlayer !== this.lastFocusedPlayer) {
@@ -274,7 +274,7 @@ export class WebglTerritoryRenderer implements TerritoryRendererStrategy {
: null;
const isBorderTile = this.game.isBorder(tile);
// Update defended and relation state in the shared buffer
// Update defended state in the shared buffer (used for checkerboard pattern).
if (owner && isBorderTile) {
const isDefended = this.game.hasUnitNearby(
tile,
@@ -282,19 +282,10 @@ export class WebglTerritoryRenderer implements TerritoryRendererStrategy {
UnitType.DefensePost,
owner.id(),
);
const { hasEmbargo, hasFriendly } = owner.borderRelationFlags(tile);
let relation = 0; // neutral
if (hasFriendly) {
relation = 1; // friendly
} else if (hasEmbargo) {
relation = 2; // embargo
}
this.game.setDefended(tile, isDefended);
this.game.setRelation(tile, relation);
} else {
// Clear defended/relation state for non-border tiles
// Clear defended state for non-border tiles
this.game.setDefended(tile, false);
this.game.setRelation(tile, 0);
}
this.renderer.markTile(tile);
@@ -52,6 +52,7 @@ export class TerritoryWebGLRenderer {
hoverPulseStrength: WebGLUniformLocation | null;
hoverPulseSpeed: WebGLUniformLocation | null;
time: WebGLUniformLocation | null;
viewerId: WebGLUniformLocation | null;
// Border color uniforms for shader-computed borders
borderNeutral: WebGLUniformLocation | null;
borderFriendly: WebGLUniformLocation | null;
@@ -126,6 +127,7 @@ export class TerritoryWebGLRenderer {
hoverPulseStrength: null,
hoverPulseSpeed: null,
time: null,
viewerId: null,
borderNeutral: null,
borderFriendly: null,
borderEmbargo: null,
@@ -167,6 +169,7 @@ export class TerritoryWebGLRenderer {
hoverPulseStrength: null,
hoverPulseSpeed: null,
time: null,
viewerId: null,
borderNeutral: null,
borderFriendly: null,
borderEmbargo: null,
@@ -208,6 +211,7 @@ export class TerritoryWebGLRenderer {
),
hoverPulseSpeed: gl.getUniformLocation(this.program, "u_hoverPulseSpeed"),
time: gl.getUniformLocation(this.program, "u_time"),
viewerId: gl.getUniformLocation(this.program, "u_viewerId"),
borderNeutral: gl.getUniformLocation(this.program, "u_borderNeutral"),
borderFriendly: gl.getUniformLocation(this.program, "u_borderFriendly"),
borderEmbargo: gl.getUniformLocation(this.program, "u_borderEmbargo"),
@@ -375,6 +379,10 @@ export class TerritoryWebGLRenderer {
c.a ?? 1,
);
}
if (this.uniforms.viewerId) {
const viewerId = this.game.myPlayer()?.smallID() ?? 0;
gl.uniform1i(this.uniforms.viewerId, viewerId);
}
if (this.uniforms.alternativeView) {
gl.uniform1i(this.uniforms.alternativeView, 0);
}
@@ -573,6 +581,10 @@ export class TerritoryWebGLRenderer {
const currentTime = (Date.now() - this.animationStartTime) / 1000.0;
gl.uniform1f(this.uniforms.time, currentTime);
}
if (this.uniforms.viewerId) {
const viewerId = this.game.myPlayer()?.smallID() ?? 0;
gl.uniform1i(this.uniforms.viewerId, viewerId);
}
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
@@ -714,13 +726,12 @@ export class TerritoryWebGLRenderer {
if (!this.gl || !this.paletteTexture || !this.relationTexture) return;
const gl = this.gl;
const players = this.game.playerViews().filter((p) => p.isPlayer());
const myPlayer = this.game.myPlayer();
const maxId = players.reduce((max, p) => Math.max(max, p.smallID()), 0) + 1;
this.paletteWidth = Math.max(maxId, 1);
const paletteData = new Uint8Array(this.paletteWidth * 8); // 8 bytes per player: territory RGBA + border RGBA
const relationData = new Uint8Array(this.paletteWidth);
const relationData = new Uint8Array(this.paletteWidth * this.paletteWidth);
for (const p of players) {
const id = p.smallID();
@@ -737,8 +748,16 @@ export class TerritoryWebGLRenderer {
paletteData[id * 8 + 5] = borderRgba.g;
paletteData[id * 8 + 6] = borderRgba.b;
paletteData[id * 8 + 7] = Math.round((borderRgba.a ?? 1) * 255);
}
relationData[id] = this.resolveRelationCode(p, myPlayer);
// Build relation matrix: friendly/embargo/self flags per owner/other pair.
for (let ownerId = 0; ownerId < this.paletteWidth; ownerId++) {
const owner = this.safePlayerBySmallId(ownerId);
for (let otherId = 0; otherId < this.paletteWidth; otherId++) {
const other = this.safePlayerBySmallId(otherId);
relationData[ownerId * this.paletteWidth + otherId] =
this.resolveRelationCode(owner, other);
}
}
gl.activeTexture(gl.TEXTURE1);
@@ -772,7 +791,7 @@ export class TerritoryWebGLRenderer {
0,
gl.R8UI,
this.paletteWidth,
1,
this.paletteWidth,
0,
gl.RED_INTEGER,
gl.UNSIGNED_BYTE,
@@ -781,22 +800,31 @@ export class TerritoryWebGLRenderer {
}
private resolveRelationCode(
owner: PlayerView,
myPlayer: PlayerView | null,
owner: PlayerView | null,
other: PlayerView | null,
): number {
if (!myPlayer) {
return 3; // Neutral
if (!owner || !other || !owner.isPlayer() || !other.isPlayer()) {
return 0; // Neutral / no relation
}
if (owner.smallID() === myPlayer.smallID()) {
return 1; // Self
let code = 0;
if (owner.smallID() === other.smallID()) {
code |= 4; // self bit
}
if (owner.isFriendly(myPlayer)) {
return 2; // Ally
// Friendly if either side is friendly toward the other.
if (owner.isFriendly(other) || other.isFriendly(owner)) {
code |= 1;
}
if (!owner.hasEmbargo(myPlayer)) {
return 3; // Neutral
// Embargo if owner has embargo against other.
if (owner.hasEmbargo(other)) {
code |= 2;
}
return 4; // Enemy
return code;
}
private safePlayerBySmallId(id: number): PlayerView | null {
const player = this.game.playerBySmallID(id);
return player instanceof PlayerView ? player : null;
}
private createProgram(gl: WebGL2RenderingContext): WebGLProgram | null {
@@ -820,6 +848,7 @@ export class TerritoryWebGLRenderer {
uniform sampler2D u_palette;
uniform usampler2D u_relations;
uniform sampler2D u_borderColor;
uniform int u_viewerId;
uniform vec2 u_resolution;
uniform vec4 u_fallout;
uniform vec4 u_altSelf;
@@ -855,6 +884,25 @@ export class TerritoryWebGLRenderer {
return texelFetch(u_state, clamped, 0).r & 0xFFFu;
}
uint relationCode(uint owner, uint other) {
if (owner == 0u || other == 0u) {
return 0u;
}
return texelFetch(u_relations, ivec2(int(owner), int(other)), 0).r;
}
bool isFriendly(uint code) {
return (code & 1u) != 0u;
}
bool isEmbargo(uint code) {
return (code & 2u) != 0u;
}
bool isSelf(uint code) {
return (code & 4u) != 0u;
}
void main() {
ivec2 fragCoord = ivec2(gl_FragCoord.xy);
// gl_FragCoord origin is bottom-left; flip Y to match top-left oriented buffers.
@@ -864,7 +912,6 @@ export class TerritoryWebGLRenderer {
uint owner = state & 0xFFFu;
bool hasFallout = (state & 0x2000u) != 0u; // bit 13
bool isDefended = (state & 0x1000u) != 0u; // bit 12
uint relation = (state & 0xC000u) >> 14u; // bits 14-15
if (owner == 0u) {
if (hasFallout) {
@@ -877,25 +924,47 @@ export class TerritoryWebGLRenderer {
return;
}
// Border detection via neighbor comparison
// Border detection via neighbor comparison and relation checks
bool isBorder = false;
bool hasFriendlyRelation = false;
bool hasEmbargoRelation = false;
uint nOwner = ownerAtTex(texCoord + ivec2(1, 0));
isBorder = isBorder || (nOwner != owner);
if (nOwner != owner && nOwner != 0u) {
uint rel = relationCode(owner, nOwner);
hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel);
hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel);
}
nOwner = ownerAtTex(texCoord + ivec2(-1, 0));
isBorder = isBorder || (nOwner != owner);
if (nOwner != owner && nOwner != 0u) {
uint rel = relationCode(owner, nOwner);
hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel);
hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel);
}
nOwner = ownerAtTex(texCoord + ivec2(0, 1));
isBorder = isBorder || (nOwner != owner);
if (nOwner != owner && nOwner != 0u) {
uint rel = relationCode(owner, nOwner);
hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel);
hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel);
}
nOwner = ownerAtTex(texCoord + ivec2(0, -1));
isBorder = isBorder || (nOwner != owner);
if (nOwner != owner && nOwner != 0u) {
uint rel = relationCode(owner, nOwner);
hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel);
hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel);
}
if (u_alternativeView) {
uint relation = texelFetch(u_relations, ivec2(int(owner), 0), 0).r;
uint relationAlt = relationCode(owner, uint(u_viewerId));
vec4 altColor = u_altNeutral;
if (relation == 1u) {
if (isSelf(relationAlt)) {
altColor = u_altSelf;
} else if (relation == 2u) {
} else if (isFriendly(relationAlt)) {
altColor = u_altAlly;
} else if (relation >= 4u) {
} else if (isEmbargo(relationAlt)) {
altColor = u_altEnemy;
}
float a = isBorder ? 1.0 : 0.0;
@@ -925,19 +994,18 @@ export class TerritoryWebGLRenderer {
const vec3 FRIENDLY_TINT_TARGET = vec3(0.0, 1.0, 0.0); // green
const vec3 EMBARGO_TINT_TARGET = vec3(1.0, 0.0, 0.0); // red
if (relation == 1u) { // friendly
if (hasFriendlyRelation) { // friendly
borderColor = borderColor * (1.0 - BORDER_TINT_RATIO) +
FRIENDLY_TINT_TARGET * BORDER_TINT_RATIO;
} else if (relation == 2u) { // embargo
}
if (hasEmbargoRelation) { // embargo
borderColor = borderColor * (1.0 - BORDER_TINT_RATIO) +
EMBARGO_TINT_TARGET * BORDER_TINT_RATIO;
}
// relation == 0u (neutral) uses base border color as-is
// Apply defended checkerboard pattern
if (isDefended) {
bool isLightTile = ((texCoord.x % 2) == (texCoord.y % 2));
// Simple checkerboard: alternate between lighter and darker versions
const float LIGHT_FACTOR = 1.2;
const float DARK_FACTOR = 0.8;
borderColor *= isLightTile ? LIGHT_FACTOR : DARK_FACTOR;