Files
OpenFrontIO/src/client/graphics/webgpu/core/GroundTruthData.ts
T
scamiv 4463f1a060 refactor: optimize terrain color extraction in GroundTruthData
Replaced tile sampling for terrain colors with direct extraction from the theme object, significantly improving performance. Updated shore, water, shoreline water, plains, highland, and mountain color computations to utilize theme properties, eliminating the need for tile searches. This change enhances efficiency in terrain color management while maintaining visual fidelity.
2026-02-05 21:46:47 +01:00

680 lines
20 KiB
TypeScript

import { Theme } from "../../../../core/configuration/Config";
import { UnitType } from "../../../../core/game/Game";
import { GameView } from "../../../../core/game/GameView";
/**
* Alignment helper for texture uploads.
*/
function align(value: number, alignment: number): number {
return Math.ceil(value / alignment) * alignment;
}
/**
* Manages authoritative GPU textures and buffers (ground truth data).
* All compute and render passes read from this data.
*/
export class GroundTruthData {
public static readonly PALETTE_RESERVED_SLOTS = 10;
public static readonly PALETTE_FALLOUT_INDEX = 0;
// Textures
public readonly stateTexture: GPUTexture;
public readonly terrainTexture: GPUTexture;
public readonly terrainDataTexture: GPUTexture;
public readonly paletteTexture: GPUTexture;
public readonly defendedTexture: GPUTexture;
// Buffers
public readonly uniformBuffer: GPUBuffer;
public readonly defenseParamsBuffer: GPUBuffer;
public readonly terrainParamsBuffer: GPUBuffer;
public updatesBuffer: GPUBuffer | null = null;
public defensePostsBuffer: GPUBuffer | null = null;
// Staging arrays for buffer uploads
private updatesStaging: Uint32Array | null = null;
private defensePostsStaging: Uint32Array | null = null;
// Buffer capacities
private updatesCapacity = 0;
private defensePostsCapacity = 0;
// State tracking
private readonly mapWidth: number;
private readonly mapHeight: number;
private readonly state: Uint16Array;
private readonly terrainData: Uint8Array;
private needsStateUpload = true;
private needsPaletteUpload = true;
private needsTerrainDataUpload = true;
private needsTerrainParamsUpload = true;
private paletteWidth = 1;
private defensePostsCount = 0;
private needsDefensePostsUpload = true;
// Uniform data arrays
private readonly uniformData = new Float32Array(12);
private readonly defenseParamsData = new Uint32Array(4);
private readonly terrainParamsData = new Float32Array(24); // 6 vec4f: shore, water, shorelineWater, plainsBase, highlandBase, mountainBase
// View state (updated by renderer)
private viewWidth = 1;
private viewHeight = 1;
private viewScale = 1;
private viewOffsetX = 0;
private viewOffsetY = 0;
private alternativeView = false;
private highlightedOwnerId = -1;
// Defense state
private defendedEpoch = 1;
private lastDefenseRange = -1;
private lastDefensePostsCount = -1;
private constructor(
private readonly device: GPUDevice,
private readonly game: GameView,
private readonly theme: Theme,
state: Uint16Array,
terrainData: Uint8Array,
mapWidth: number,
mapHeight: number,
) {
this.state = state;
this.terrainData = terrainData;
this.mapWidth = mapWidth;
this.mapHeight = mapHeight;
const GPUBufferUsage = (globalThis as any).GPUBufferUsage;
const GPUTextureUsage = (globalThis as any).GPUTextureUsage;
const UNIFORM = GPUBufferUsage?.UNIFORM ?? 0x40;
const COPY_DST_BUF = GPUBufferUsage?.COPY_DST ?? 0x8;
const COPY_DST_TEX = GPUTextureUsage?.COPY_DST ?? 0x2;
const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4;
const STORAGE_BINDING = GPUTextureUsage?.STORAGE_BINDING ?? 0x8;
// Render uniforms: 3x vec4f = 48 bytes
this.uniformBuffer = device.createBuffer({
size: 48,
usage: UNIFORM | COPY_DST_BUF,
});
// Defense params: 4x u32 = 16 bytes
this.defenseParamsBuffer = device.createBuffer({
size: 16,
usage: UNIFORM | COPY_DST_BUF,
});
// Terrain params: 6x vec4f = 96 bytes (shore, water, shorelineWater, plainsBase, highlandBase, mountainBase)
this.terrainParamsBuffer = device.createBuffer({
size: 96,
usage: UNIFORM | COPY_DST_BUF,
});
// State texture (r32uint)
this.stateTexture = device.createTexture({
size: { width: mapWidth, height: mapHeight },
format: "r32uint",
usage: COPY_DST_TEX | TEXTURE_BINDING | STORAGE_BINDING,
});
// Defended texture (r32uint)
this.defendedTexture = device.createTexture({
size: { width: mapWidth, height: mapHeight },
format: "r32uint",
usage: TEXTURE_BINDING | STORAGE_BINDING,
});
// Palette texture (rgba8unorm)
this.paletteTexture = device.createTexture({
size: { width: 1, height: 1 },
format: "rgba8unorm",
usage: COPY_DST_TEX | TEXTURE_BINDING,
});
// Terrain texture (rgba8unorm) - output of terrain compute shader
this.terrainTexture = device.createTexture({
size: { width: mapWidth, height: mapHeight },
format: "rgba8unorm",
usage: COPY_DST_TEX | TEXTURE_BINDING | STORAGE_BINDING,
});
// Terrain data texture (r8uint) - input terrain data (read-only in compute shader)
this.terrainDataTexture = device.createTexture({
size: { width: mapWidth, height: mapHeight },
format: "r8uint",
usage: COPY_DST_TEX | TEXTURE_BINDING,
});
}
static create(
device: GPUDevice,
game: GameView,
theme: Theme,
state: Uint16Array,
): GroundTruthData {
return new GroundTruthData(
device,
game,
theme,
state,
game.terrainDataView(),
game.width(),
game.height(),
);
}
// =====================
// View state setters
// =====================
setViewSize(width: number, height: number): void {
this.viewWidth = Math.max(1, Math.floor(width));
this.viewHeight = Math.max(1, Math.floor(height));
}
setViewTransform(scale: number, offsetX: number, offsetY: number): void {
this.viewScale = scale;
this.viewOffsetX = offsetX;
this.viewOffsetY = offsetY;
}
setAlternativeView(enabled: boolean): void {
this.alternativeView = enabled;
}
setHighlightedOwnerId(ownerSmallId: number | null): void {
this.highlightedOwnerId = ownerSmallId ?? -1;
}
// =====================
// Upload methods
// =====================
uploadState(): void {
if (!this.needsStateUpload) {
return;
}
this.needsStateUpload = false;
// Convert 16-bit CPU state to 32-bit array
const u32State = new Uint32Array(this.state.length);
for (let i = 0; i < this.state.length; i++) {
u32State[i] = this.state[i];
}
const bytesPerTexel = Uint32Array.BYTES_PER_ELEMENT;
const fullBytesPerRow = this.mapWidth * bytesPerTexel;
if (fullBytesPerRow % 256 === 0) {
this.device.queue.writeTexture(
{ texture: this.stateTexture },
u32State,
{ bytesPerRow: fullBytesPerRow, rowsPerImage: this.mapHeight },
{
width: this.mapWidth,
height: this.mapHeight,
depthOrArrayLayers: 1,
},
);
} else {
// Fallback: upload row-by-row with padding
const paddedBytesPerRow = align(fullBytesPerRow, 256);
const scratch = new Uint32Array(paddedBytesPerRow / 4);
for (let y = 0; y < this.mapHeight; y++) {
const start = y * this.mapWidth;
scratch.set(u32State.subarray(start, start + this.mapWidth), 0);
this.device.queue.writeTexture(
{ texture: this.stateTexture, origin: { x: 0, y } },
scratch,
{ bytesPerRow: paddedBytesPerRow, rowsPerImage: 1 },
{ width: this.mapWidth, height: 1, depthOrArrayLayers: 1 },
);
}
}
}
/**
* @deprecated Use terrain compute shader instead. This method is kept for fallback.
*/
uploadTerrain(): void {
const bytesPerRow = this.mapWidth * 4;
const paddedBytesPerRow = align(bytesPerRow, 256);
const row = new Uint8Array(paddedBytesPerRow);
const toByte = (value: number): number =>
Math.max(0, Math.min(255, Math.round(value)));
for (let y = 0; y < this.mapHeight; y++) {
row.fill(0);
for (let x = 0; x < this.mapWidth; x++) {
const tile = y * this.mapWidth + x;
const rgba = this.theme.terrainColor(this.game, tile).rgba;
const idx = x * 4;
row[idx] = toByte(rgba.r);
row[idx + 1] = toByte(rgba.g);
row[idx + 2] = toByte(rgba.b);
row[idx + 3] = 255;
}
this.device.queue.writeTexture(
{ texture: this.terrainTexture, origin: { x: 0, y } },
row,
{ bytesPerRow: paddedBytesPerRow, rowsPerImage: 1 },
{ width: this.mapWidth, height: 1, depthOrArrayLayers: 1 },
);
}
}
uploadTerrainData(): void {
if (!this.needsTerrainDataUpload) {
return;
}
this.needsTerrainDataUpload = false;
const bytesPerRow = this.mapWidth;
const paddedBytesPerRow = align(bytesPerRow, 256);
if (paddedBytesPerRow === bytesPerRow) {
// Direct upload if already aligned
this.device.queue.writeTexture(
{ texture: this.terrainDataTexture },
this.terrainData,
{ bytesPerRow, rowsPerImage: this.mapHeight },
{
width: this.mapWidth,
height: this.mapHeight,
depthOrArrayLayers: 1,
},
);
} else {
// Row-by-row upload with padding
const row = new Uint8Array(paddedBytesPerRow);
for (let y = 0; y < this.mapHeight; y++) {
row.fill(0);
const start = y * this.mapWidth;
row.set(this.terrainData.subarray(start, start + this.mapWidth), 0);
this.device.queue.writeTexture(
{ texture: this.terrainDataTexture, origin: { x: 0, y } },
row,
{ bytesPerRow: paddedBytesPerRow, rowsPerImage: 1 },
{ width: this.mapWidth, height: 1, depthOrArrayLayers: 1 },
);
}
}
}
uploadTerrainParams(): void {
if (!this.needsTerrainParamsUpload) {
return;
}
this.needsTerrainParamsUpload = false;
// Extract theme colors directly from theme object (much faster than sampling tiles)
const themeAny = this.theme as any;
const isDark = themeAny.darkShore !== undefined;
// Get shore color
const shore = isDark ? themeAny.darkShore : themeAny.shore;
const shoreColor = shore?.rgba ?? { r: 204, g: 203, b: 158, a: 255 };
// Get water colors
const water = isDark ? themeAny.darkWater : themeAny.water;
const waterColor = water?.rgba ?? { r: 70, g: 132, b: 180, a: 255 };
const shorelineWater = isDark
? themeAny.darkShorelineWater
: themeAny.shorelineWater;
const shorelineWaterColor = shorelineWater?.rgba ?? {
r: 100,
g: 143,
b: 255,
a: 255,
};
// Compute terrain base colors from formulas (no tile sampling needed)
// Plains at mag 0: rgb(190, 220, 138) for pastel, rgb(140, 170, 88) for dark
const plainsColor = isDark
? { r: 140, g: 170, b: 88, a: 255 }
: { r: 190, g: 220, b: 138, a: 255 };
// Highland at mag 10: rgb(220, 203, 158) for pastel, rgb(170, 153, 108) for dark
const highlandColor = isDark
? { r: 170, g: 153, b: 108, a: 255 }
: { r: 220, g: 203, b: 158, a: 255 };
// Mountain at mag 20: rgb(240, 240, 240) for pastel, rgb(190, 190, 190) for dark
const mountainColor = isDark
? { r: 190, g: 190, b: 190, a: 255 }
: { r: 240, g: 240, b: 240, a: 255 };
// Store colors as vec4f (RGBA normalized to 0-1)
// Index 0-3: shore color
this.terrainParamsData[0] = shoreColor.r / 255;
this.terrainParamsData[1] = shoreColor.g / 255;
this.terrainParamsData[2] = shoreColor.b / 255;
this.terrainParamsData[3] = 1.0;
// Index 4-7: water base color
this.terrainParamsData[4] = waterColor.r / 255;
this.terrainParamsData[5] = waterColor.g / 255;
this.terrainParamsData[6] = waterColor.b / 255;
this.terrainParamsData[7] = 1.0;
// Index 8-11: shoreline water color
this.terrainParamsData[8] = shorelineWaterColor.r / 255;
this.terrainParamsData[9] = shorelineWaterColor.g / 255;
this.terrainParamsData[10] = shorelineWaterColor.b / 255;
this.terrainParamsData[11] = 1.0;
// Index 12-15: plains base color (magnitude 0)
this.terrainParamsData[12] = plainsColor.r / 255;
this.terrainParamsData[13] = plainsColor.g / 255;
this.terrainParamsData[14] = plainsColor.b / 255;
this.terrainParamsData[15] = 1.0;
// Index 16-19: highland base color (magnitude 10)
this.terrainParamsData[16] = highlandColor.r / 255;
this.terrainParamsData[17] = highlandColor.g / 255;
this.terrainParamsData[18] = highlandColor.b / 255;
this.terrainParamsData[19] = 1.0;
// Index 20-23: mountain base color (magnitude 20)
this.terrainParamsData[20] = mountainColor.r / 255;
this.terrainParamsData[21] = mountainColor.g / 255;
this.terrainParamsData[22] = mountainColor.b / 255;
this.terrainParamsData[23] = 1.0;
this.device.queue.writeBuffer(
this.terrainParamsBuffer,
0,
this.terrainParamsData,
);
}
markTerrainParamsDirty(): void {
this.needsTerrainParamsUpload = true;
}
uploadPalette(): boolean {
if (!this.needsPaletteUpload) {
return false;
}
this.needsPaletteUpload = false;
let maxSmallId = 0;
for (const player of this.game.playerViews()) {
maxSmallId = Math.max(maxSmallId, player.smallID());
}
const nextPaletteWidth =
GroundTruthData.PALETTE_RESERVED_SLOTS + Math.max(1, maxSmallId + 1);
let textureRecreated = false;
if (nextPaletteWidth !== this.paletteWidth) {
this.paletteWidth = nextPaletteWidth;
(this.paletteTexture as any).destroy?.();
const GPUTextureUsage = (globalThis as any).GPUTextureUsage;
const COPY_DST_TEX = GPUTextureUsage?.COPY_DST ?? 0x2;
const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4;
(this as any).paletteTexture = this.device.createTexture({
size: { width: this.paletteWidth, height: 1 },
format: "rgba8unorm",
usage: COPY_DST_TEX | TEXTURE_BINDING,
});
textureRecreated = true;
}
const bytes = new Uint8Array(this.paletteWidth * 4);
// Store special colors in reserved slots (0-9)
const falloutIdx = GroundTruthData.PALETTE_FALLOUT_INDEX * 4;
bytes[falloutIdx] = 120;
bytes[falloutIdx + 1] = 255;
bytes[falloutIdx + 2] = 71;
bytes[falloutIdx + 3] = 255;
// Store player colors starting at index 10
for (const player of this.game.playerViews()) {
const id = player.smallID();
if (id <= 0) continue;
const rgba = player.territoryColor().rgba;
const idx = (GroundTruthData.PALETTE_RESERVED_SLOTS + id) * 4;
bytes[idx] = rgba.r;
bytes[idx + 1] = rgba.g;
bytes[idx + 2] = rgba.b;
bytes[idx + 3] = 255;
}
const bytesPerRow = align(this.paletteWidth * 4, 256);
const padded =
bytesPerRow === this.paletteWidth * 4
? bytes
: (() => {
const tmp = new Uint8Array(bytesPerRow);
tmp.set(bytes);
return tmp;
})();
this.device.queue.writeTexture(
{ texture: this.paletteTexture },
padded,
{ bytesPerRow, rowsPerImage: 1 },
{ width: this.paletteWidth, height: 1, depthOrArrayLayers: 1 },
);
return textureRecreated;
}
uploadDefensePosts(): void {
if (!this.needsDefensePostsUpload) {
return;
}
this.needsDefensePostsUpload = false;
const posts = this.collectDefensePosts();
this.defensePostsCount = posts.length;
if (this.defensePostsCount > 0) {
this.ensureDefensePostsBuffer(this.defensePostsCount);
}
if (
this.defensePostsCount > 0 &&
this.defensePostsStaging &&
this.defensePostsBuffer
) {
for (let i = 0; i < this.defensePostsCount; i++) {
const p = posts[i];
this.defensePostsStaging[i * 3] = p.x >>> 0;
this.defensePostsStaging[i * 3 + 1] = p.y >>> 0;
this.defensePostsStaging[i * 3 + 2] = p.ownerId >>> 0;
}
this.device.queue.writeBuffer(
this.defensePostsBuffer,
0,
this.defensePostsStaging.subarray(0, this.defensePostsCount * 3),
);
}
}
private collectDefensePosts(): Array<{
x: number;
y: number;
ownerId: number;
}> {
const posts: Array<{ x: number; y: number; ownerId: number }> = [];
const units = this.game.units(UnitType.DefensePost) as any[];
for (const u of units) {
if (!u.isActive() || u.isUnderConstruction()) {
continue;
}
const tile = u.tile();
posts.push({
x: this.game.x(tile),
y: this.game.y(tile),
ownerId: u.owner().smallID(),
});
}
return posts;
}
private ensureDefensePostsBuffer(capacity: number): void {
if (this.defensePostsBuffer && capacity <= this.defensePostsCapacity) {
return;
}
const GPUBufferUsage = (globalThis as any).GPUBufferUsage;
const STORAGE = GPUBufferUsage?.STORAGE ?? 0x10;
const COPY_DST_BUF = GPUBufferUsage?.COPY_DST ?? 0x8;
this.defensePostsCapacity = Math.max(
8,
Math.pow(2, Math.ceil(Math.log2(Math.max(1, capacity)))),
);
const bytesPerPost = 12; // 3 * u32
const bufferSize = this.defensePostsCapacity * bytesPerPost;
if (this.defensePostsBuffer) {
(this.defensePostsBuffer as any).destroy?.();
}
(this as any).defensePostsBuffer = this.device.createBuffer({
size: bufferSize,
usage: STORAGE | COPY_DST_BUF,
});
this.defensePostsStaging = new Uint32Array(this.defensePostsCapacity * 3);
}
ensureUpdatesBuffer(capacity: number): GPUBuffer {
if (this.updatesBuffer && capacity <= this.updatesCapacity) {
return this.updatesBuffer;
}
const GPUBufferUsage = (globalThis as any).GPUBufferUsage;
const STORAGE = GPUBufferUsage?.STORAGE ?? 0x10;
const COPY_DST_BUF = GPUBufferUsage?.COPY_DST ?? 0x8;
this.updatesCapacity = Math.max(
256,
Math.pow(2, Math.ceil(Math.log2(capacity))),
);
const bufferSize = this.updatesCapacity * 8; // Each update is 8 bytes
if (this.updatesBuffer) {
(this.updatesBuffer as any).destroy?.();
}
(this as any).updatesBuffer = this.device.createBuffer({
size: bufferSize,
usage: STORAGE | COPY_DST_BUF,
});
this.updatesStaging = new Uint32Array(this.updatesCapacity * 2);
return this.updatesBuffer;
}
getUpdatesStaging(): Uint32Array {
this.updatesStaging ??= new Uint32Array(this.updatesCapacity * 2);
return this.updatesStaging;
}
// =====================
// Uniform buffer updates
// =====================
writeUniformBuffer(timeSec: number): void {
this.uniformData[0] = this.mapWidth;
this.uniformData[1] = this.mapHeight;
this.uniformData[2] = this.viewScale;
this.uniformData[3] = timeSec;
this.uniformData[4] = this.viewOffsetX;
this.uniformData[5] = this.viewOffsetY;
this.uniformData[6] = this.alternativeView ? 1 : 0;
this.uniformData[7] = this.highlightedOwnerId;
this.uniformData[8] = this.viewWidth;
this.uniformData[9] = this.viewHeight;
this.uniformData[10] = 0;
this.uniformData[11] = 0;
this.device.queue.writeBuffer(this.uniformBuffer, 0, this.uniformData);
}
writeDefenseParamsBuffer(): void {
const range = this.game.config().defensePostRange() >>> 0;
this.defenseParamsData[0] = range;
this.defenseParamsData[1] = this.defensePostsCount >>> 0;
this.defenseParamsData[2] = this.defendedEpoch >>> 0;
this.defenseParamsData[3] = 0;
this.device.queue.writeBuffer(
this.defenseParamsBuffer,
0,
this.defenseParamsData,
);
}
// =====================
// State getters/setters
// =====================
getDefendedEpoch(): number {
return this.defendedEpoch;
}
incrementDefendedEpoch(): void {
this.defendedEpoch = (this.defendedEpoch + 1) >>> 0;
if (this.defendedEpoch === 0) {
this.defendedEpoch = 1;
}
}
getDefensePostsCount(): number {
return this.defensePostsCount;
}
getLastDefenseRange(): number {
return this.lastDefenseRange;
}
setLastDefenseRange(range: number): void {
this.lastDefenseRange = range;
}
getLastDefensePostsCount(): number {
return this.lastDefensePostsCount;
}
setLastDefensePostsCount(count: number): void {
this.lastDefensePostsCount = count;
}
markPaletteDirty(): void {
this.needsPaletteUpload = true;
}
markDefensePostsDirty(): void {
this.needsDefensePostsUpload = true;
}
getState(): Uint16Array {
return this.state;
}
getMapWidth(): number {
return this.mapWidth;
}
getMapHeight(): number {
return this.mapHeight;
}
getGame(): GameView {
return this.game;
}
getTheme(): Theme {
return this.theme;
}
}