diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 45d1188a3..6ffc3b161 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -136,6 +136,10 @@ export class TickMetricsEvent implements GameEvent { ) {} } +export class WebGPUComputeMetricsEvent implements GameEvent { + constructor(public readonly computeMs: number) {} +} + export class InputHandler { private lastPointerX: number = 0; private lastPointerY: number = 0; diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 0ada4565d..4e27a42c3 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -371,16 +371,6 @@ export class UserSettingModal extends BaseModal { console.log("🏳️ Territory Patterns:", enabled ? "ON" : "OFF"); } - private changeTerritoryBorderMode(e: CustomEvent<{ value: string }>) { - const value = e.detail?.value; - if (typeof value !== "string") return; - - const mode = parseInt(value, 10); - if (!Number.isFinite(mode)) return; - - this.userSettings.setInt("settings.territoryBorderMode", mode); - } - private togglePerformanceOverlay(e: CustomEvent<{ checked: boolean }>) { const enabled = e.detail?.checked; if (typeof enabled !== "boolean") return; @@ -805,21 +795,6 @@ export class UserSettingModal extends BaseModal { this.toggleDarkMode(e)} > - - 50) { console.warn( diff --git a/src/client/graphics/layers/SettingsModal.ts b/src/client/graphics/layers/SettingsModal.ts index db0cf14b8..a18793a19 100644 --- a/src/client/graphics/layers/SettingsModal.ts +++ b/src/client/graphics/layers/SettingsModal.ts @@ -141,16 +141,6 @@ export class SettingsModal extends LitElement implements Layer { this.requestUpdate(); } - private onTerritoryBorderModeChange(event: Event) { - const value = (event.target as HTMLSelectElement).value; - const mode = Number.parseInt(value, 10); - if (!Number.isFinite(mode)) - throw new Error(`Invalid border mode: ${value}`); - - this.userSettings.setInt("settings.territoryBorderMode", mode); - this.requestUpdate(); - } - private onToggleRandomNameModeButtonClick() { this.userSettings.toggleRandomName(); this.requestUpdate(); @@ -171,6 +161,11 @@ export class SettingsModal extends LitElement implements Layer { this.requestUpdate(); } + private onToggleWebgpuDebugOverlayButtonClick() { + this.userSettings.toggleWebgpuDebug(); + this.requestUpdate(); + } + private onExitButtonClick() { // redirect to the home page window.location.href = "/"; @@ -296,34 +291,6 @@ export class SettingsModal extends LitElement implements Layer { - - - - - ${translateText("user_setting.territory_border_mode_label")} - - - ${translateText("user_setting.territory_border_mode_desc")} - - - - Off - Simple - Glow - - - + + + + WebGPU Debug + + Territory shader selection + options + + + + ${this.userSettings.webgpuDebug() + ? translateText("user_setting.on") + : translateText("user_setting.off")} + + + { + if (typeof e.computeMs === "number" && Number.isFinite(e.computeMs)) { + this.tickComputeMs = e.computeMs; + this.requestUpdate(); + } + }); + this.requestUpdate(); + } + + updateFrameMetrics(frameDurationMs: number): void { + if (!this.userSettings || !this.userSettings.webgpuDebug()) { + return; + } + + if (!Number.isFinite(frameDurationMs) || frameDurationMs <= 0) { + return; + } + + this.frameTimes.push(frameDurationMs); + if (this.frameTimes.length > 60) { + this.frameTimes.shift(); + } + + const avgMs = + this.frameTimes.reduce((a, b) => a + b, 0) / this.frameTimes.length; + this.renderFps = Math.round(1000 / Math.max(1e-6, avgMs)); + this.requestUpdate(); + } + + private selectedShaderId() { + const selected = this.userSettings.getInt(TERRITORY_SHADER_KEY, 0); + return territoryShaderIdFromInt(selected); + } + + private setSelectedShaderId(id: "classic" | "retro") { + this.userSettings.setInt( + TERRITORY_SHADER_KEY, + territoryShaderIntFromId(id), + ); + this.requestUpdate(); + } + + private renderOptionControl( + option: (typeof TERRITORY_SHADERS)[number]["options"][number], + ) { + if (option.kind === "boolean") { + const enabled = this.userSettings.get(option.key, option.defaultValue); + return html` + + ${option.label} + { + const checked = (e.target as HTMLInputElement).checked; + this.userSettings.set(option.key, checked); + this.requestUpdate(); + }} + /> + + `; + } + + if (option.kind === "enum") { + const value = this.userSettings.getInt(option.key, option.defaultValue); + return html` + + ${option.label} + { + const raw = (e.target as HTMLSelectElement).value; + const next = Number.parseInt(raw, 10); + if (!Number.isFinite(next)) return; + this.userSettings.setInt(option.key, next); + this.requestUpdate(); + }} + > + ${option.options.map( + (o) => html`${o.label}`, + )} + + + `; + } + + const value = this.userSettings.getFloat(option.key, option.defaultValue); + return html` + + ${option.label} + + { + const raw = (e.target as HTMLInputElement).value; + const next = Number.parseFloat(raw); + if (!Number.isFinite(next)) return; + this.userSettings.setFloat(option.key, next); + this.requestUpdate(); + }} + /> + ${value.toFixed(2)} + + + `; + } + + render() { + if (!this.userSettings || !this.userSettings.webgpuDebug()) { + return null; + } + + const shaderId = this.selectedShaderId(); + const shader = + TERRITORY_SHADERS.find((s) => s.id === shaderId) ?? TERRITORY_SHADERS[0]; + + return html` + + + WebGPU Debug + + + + + tick ms compute + ${this.tickComputeMs.toFixed(2)} + + + render fps + ${this.renderFps} + + + + + Territory Shader + { + const raw = (e.target as HTMLSelectElement).value; + const next = territoryShaderIdFromInt(Number.parseInt(raw, 10)); + this.setSelectedShaderId(next); + }} + > + ${TERRITORY_SHADERS.map( + (s) => + html` + ${s.label} + `, + )} + + + + ${shader.options.map((opt) => this.renderOptionControl(opt))} + + `; + } +} diff --git a/src/client/graphics/webgpu/TerritoryRenderer.ts b/src/client/graphics/webgpu/TerritoryRenderer.ts index 798682e7a..3cebb41c1 100644 --- a/src/client/graphics/webgpu/TerritoryRenderer.ts +++ b/src/client/graphics/webgpu/TerritoryRenderer.ts @@ -28,7 +28,9 @@ export class TerritoryRenderer { private resources: GroundTruthData | null = null; private ready = false; private initPromise: Promise | null = null; - private borderMode = 1; + private territoryShaderPath = "render/territory.wgsl"; + private territoryShaderParams0 = new Float32Array(4); + private territoryShaderParams1 = new Float32Array(4); // Compute passes private computePasses: ComputePass[] = []; @@ -99,8 +101,10 @@ export class TerritoryRenderer { this.theme, state, ); - - this.resources.setBorderMode(this.borderMode); + this.resources.setTerritoryShaderParams( + this.territoryShaderParams0, + this.territoryShaderParams1, + ); // Upload terrain data and params (terrain colors will be computed on GPU) this.resources.uploadTerrainData(); @@ -136,6 +140,10 @@ export class TerritoryRenderer { ); } + if (this.territoryRenderPass) { + await this.territoryRenderPass.setShader(this.territoryShaderPath); + } + // Compute dependency order (topological sort) this.computePassOrder = this.topologicalSort(this.computePasses); this.renderPassOrder = this.topologicalSort(this.renderPasses); @@ -230,12 +238,29 @@ export class TerritoryRenderer { this.resources.setHighlightedOwnerId(ownerSmallId); } - setBorderMode(mode: number): void { - this.borderMode = mode; + setTerritoryShader(shaderPath: string): void { + this.territoryShaderPath = shaderPath; + if (this.territoryRenderPass) { + void this.territoryRenderPass.setShader(shaderPath); + } + } + + setTerritoryShaderParams( + params0: Float32Array | number[], + params1: Float32Array | number[], + ): void { + for (let i = 0; i < 4; i++) { + this.territoryShaderParams0[i] = Number(params0[i] ?? 0); + this.territoryShaderParams1[i] = Number(params1[i] ?? 0); + } + if (!this.resources) { return; } - this.resources.setBorderMode(mode); + this.resources.setTerritoryShaderParams( + this.territoryShaderParams0, + this.territoryShaderParams1, + ); } markTile(tile: TileRef): void { @@ -322,6 +347,9 @@ export class TerritoryRenderer { // Upload palette if needed this.resources.uploadPalette(); + // Upload diplomacy relations (used by retro shader / debug modes) + this.resources.uploadRelations(); + // Upload defense posts if needed (also produces defended dirty tiles on changes) this.resources.uploadDefensePosts(); diff --git a/src/client/graphics/webgpu/core/GroundTruthData.ts b/src/client/graphics/webgpu/core/GroundTruthData.ts index 9bf5a1b73..bef95e294 100644 --- a/src/client/graphics/webgpu/core/GroundTruthData.ts +++ b/src/client/graphics/webgpu/core/GroundTruthData.ts @@ -23,6 +23,8 @@ export class GroundTruthData { public readonly terrainTexture: GPUTexture; public readonly terrainDataTexture: GPUTexture; public readonly paletteTexture: GPUTexture; + public readonly ownerIndexTexture: GPUTexture; + public readonly relationsTexture: GPUTexture; public readonly defendedStrengthTexture: GPUTexture; // Buffers @@ -65,7 +67,7 @@ export class GroundTruthData { private defenseCircleOffsets: Int16Array = new Int16Array(0); // [dx0, dy0, dx1, dy1, ...] // Uniform data arrays - private readonly uniformData = new Float32Array(12); + private readonly uniformData = new Float32Array(20); private readonly terrainParamsData = new Float32Array(24); // 6 vec4f: shore, water, shorelineWater, plainsBase, highlandBase, mountainBase private readonly stateUpdateParamsData = new Uint32Array(4); // updateCount, range, pad, pad private readonly defendedStrengthParamsData = new Uint32Array(4); // dirtyCount, range, pad, pad @@ -78,7 +80,13 @@ export class GroundTruthData { private viewOffsetY = 0; private alternativeView = false; private highlightedOwnerId = -1; - private borderMode = 1; + + private territoryShaderParams0 = new Float32Array(4); + private territoryShaderParams1 = new Float32Array(4); + + private paletteMaxSmallId = 0; + private ownerIndexWidth = 1; + private relationsSize = 1; private constructor( private readonly device: GPUDevice, @@ -102,9 +110,9 @@ export class GroundTruthData { const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4; const STORAGE_BINDING = GPUTextureUsage?.STORAGE_BINDING ?? 0x8; - // Render uniforms: 3x vec4f = 48 bytes + // Render uniforms: 5x vec4f = 80 bytes this.uniformBuffer = device.createBuffer({ - size: 48, + size: 80, usage: UNIFORM | COPY_DST_BUF, }); @@ -140,13 +148,27 @@ export class GroundTruthData { usage: TEXTURE_BINDING | STORAGE_BINDING, }); - // Palette texture (rgba8unorm) + // Palette texture (rgba8unorm): row 0 territory colors, row 1 border colors this.paletteTexture = device.createTexture({ - size: { width: 1, height: 1 }, + size: { width: 1, height: 2 }, format: "rgba8unorm", usage: COPY_DST_TEX | TEXTURE_BINDING, }); + // SmallID -> dense index lookup texture (r32uint) + this.ownerIndexTexture = device.createTexture({ + size: { width: 1, height: 1 }, + format: "r32uint", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + + // Dense relation matrix texture (r8uint) + this.relationsTexture = device.createTexture({ + size: { width: 1, height: 1 }, + format: "r8uint", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + // Terrain texture (rgba8unorm) - output of terrain compute shader this.terrainTexture = device.createTexture({ size: { width: mapWidth, height: mapHeight }, @@ -224,8 +246,14 @@ export class GroundTruthData { this.highlightedOwnerId = ownerSmallId ?? -1; } - setBorderMode(mode: number): void { - this.borderMode = Math.max(0, Math.min(2, Math.trunc(mode))); + setTerritoryShaderParams( + params0: Float32Array | number[], + params1: Float32Array | number[], + ): void { + for (let i = 0; i < 4; i++) { + this.territoryShaderParams0[i] = Number(params0[i] ?? 0); + this.territoryShaderParams1[i] = Number(params1[i] ?? 0); + } } // ===================== @@ -447,6 +475,7 @@ export class GroundTruthData { for (const player of this.game.playerViews()) { maxSmallId = Math.max(maxSmallId, player.smallID()); } + this.paletteMaxSmallId = maxSmallId; const nextPaletteWidth = GroundTruthData.PALETTE_RESERVED_SLOTS + Math.max(1, maxSmallId + 1); @@ -458,21 +487,23 @@ export class GroundTruthData { 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 }, + size: { width: this.paletteWidth, height: 2 }, format: "rgba8unorm", usage: COPY_DST_TEX | TEXTURE_BINDING, }); textureRecreated = true; } - const bytes = new Uint8Array(this.paletteWidth * 4); + const rowStride = this.paletteWidth * 4; + const row0 = new Uint8Array(rowStride); + const row1 = new Uint8Array(rowStride); // 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; + row0[falloutIdx] = 120; + row0[falloutIdx + 1] = 255; + row0[falloutIdx + 2] = 71; + row0[falloutIdx + 3] = 255; // Store player colors starting at index 10 for (const player of this.game.playerViews()) { @@ -480,27 +511,126 @@ export class GroundTruthData { 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; + row0[idx] = rgba.r; + row0[idx + 1] = rgba.g; + row0[idx + 2] = rgba.b; + row0[idx + 3] = 255; + + const borderRgba = player.borderColor().rgba; + row1[idx] = borderRgba.r; + row1[idx + 1] = borderRgba.g; + row1[idx + 2] = borderRgba.b; + row1[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; - })(); + const bytesPerRow = align(rowStride, 256); + const padded = new Uint8Array(bytesPerRow * 2); + padded.set(row0, 0); + padded.set(row1, bytesPerRow); this.device.queue.writeTexture( { texture: this.paletteTexture }, padded, - { bytesPerRow, rowsPerImage: 1 }, - { width: this.paletteWidth, height: 1, depthOrArrayLayers: 1 }, + { bytesPerRow, rowsPerImage: 2 }, + { width: this.paletteWidth, height: 2, depthOrArrayLayers: 1 }, + ); + + return textureRecreated; + } + + uploadRelations(): boolean { + const players = this.game + .playerViews() + .filter((p) => p.smallID() > 0) + .slice() + .sort((a, b) => a.smallID() - b.smallID()); + + const maxSmallId = this.paletteMaxSmallId; + const nextOwnerIndexWidth = Math.max(1, maxSmallId + 1); + + let textureRecreated = false; + + if (nextOwnerIndexWidth !== this.ownerIndexWidth) { + this.ownerIndexWidth = nextOwnerIndexWidth; + (this.ownerIndexTexture 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).ownerIndexTexture = this.device.createTexture({ + size: { width: this.ownerIndexWidth, height: 1 }, + format: "r32uint", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + textureRecreated = true; + } + + const denseBySmallId = new Uint32Array(this.ownerIndexWidth); + let dense = 0; + for (const p of players) { + const id = p.smallID(); + if (id <= 0 || id >= this.ownerIndexWidth) continue; + dense++; + denseBySmallId[id] = dense; + } + + const ownerIndexBytesPerRow = align(this.ownerIndexWidth * 4, 256); + const ownerIndexPaddedU32 = new Uint32Array(ownerIndexBytesPerRow / 4); + ownerIndexPaddedU32.set(denseBySmallId); + this.device.queue.writeTexture( + { texture: this.ownerIndexTexture }, + ownerIndexPaddedU32, + { bytesPerRow: ownerIndexBytesPerRow, rowsPerImage: 1 }, + { width: this.ownerIndexWidth, height: 1, depthOrArrayLayers: 1 }, + ); + + const nextRelationsSize = Math.max(1, dense + 1); + if (nextRelationsSize !== this.relationsSize) { + this.relationsSize = nextRelationsSize; + (this.relationsTexture 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).relationsTexture = this.device.createTexture({ + size: { width: this.relationsSize, height: this.relationsSize }, + format: "r8uint", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + textureRecreated = true; + } + + const relBytesPerRow = align(this.relationsSize, 256); + const relPadded = new Uint8Array(relBytesPerRow * this.relationsSize); + + // 0 = neutral, 1 = friendly, 2 = embargo + for (let i = 0; i < players.length; i++) { + for (let j = i + 1; j < players.length; j++) { + const a = players[i]; + const b = players[j]; + const aDense = denseBySmallId[a.smallID()]; + const bDense = denseBySmallId[b.smallID()]; + if (aDense === 0 || bDense === 0) continue; + + let code = 0; + if (a.hasEmbargo(b)) { + code = 2; + } else if (a.isFriendly(b) || b.isFriendly(a)) { + code = 1; + } + + relPadded[aDense + bDense * relBytesPerRow] = code; + relPadded[bDense + aDense * relBytesPerRow] = code; + } + } + + this.device.queue.writeTexture( + { texture: this.relationsTexture }, + relPadded, + { bytesPerRow: relBytesPerRow, rowsPerImage: this.relationsSize }, + { + width: this.relationsSize, + height: this.relationsSize, + depthOrArrayLayers: 1, + }, ); return textureRecreated; @@ -853,9 +983,19 @@ export class GroundTruthData { this.uniformData[7] = this.highlightedOwnerId; this.uniformData[8] = this.viewWidth; this.uniformData[9] = this.viewHeight; - this.uniformData[10] = this.borderMode; + this.uniformData[10] = this.game.myPlayer()?.smallID() ?? 0; this.uniformData[11] = 0; + this.uniformData[12] = this.territoryShaderParams0[0]; + this.uniformData[13] = this.territoryShaderParams0[1]; + this.uniformData[14] = this.territoryShaderParams0[2]; + this.uniformData[15] = this.territoryShaderParams0[3]; + + this.uniformData[16] = this.territoryShaderParams1[0]; + this.uniformData[17] = this.territoryShaderParams1[1]; + this.uniformData[18] = this.territoryShaderParams1[2]; + this.uniformData[19] = this.territoryShaderParams1[3]; + this.device.queue.writeBuffer(this.uniformBuffer, 0, this.uniformData); } diff --git a/src/client/graphics/webgpu/render/TerritoryRenderPass.ts b/src/client/graphics/webgpu/render/TerritoryRenderPass.ts index b5d30bc86..34f5f278a 100644 --- a/src/client/graphics/webgpu/render/TerritoryRenderPass.ts +++ b/src/client/graphics/webgpu/render/TerritoryRenderPass.ts @@ -16,6 +16,7 @@ export class TerritoryRenderPass implements RenderPass { private device: GPUDevice | null = null; private resources: GroundTruthData | null = null; private canvasFormat: GPUTextureFormat | null = null; + private shaderPath = "render/territory.wgsl"; private clearR = 0; private clearG = 0; private clearB = 0; @@ -29,9 +30,6 @@ export class TerritoryRenderPass implements RenderPass { this.resources = resources; this.canvasFormat = canvasFormat; - const shaderCode = await loadShader("render/territory.wgsl"); - const shaderModule = device.createShaderModule({ code: shaderCode }); - this.bindGroupLayout = device.createBindGroupLayout({ entries: [ { @@ -59,21 +57,20 @@ export class TerritoryRenderPass implements RenderPass { visibility: 2 /* FRAGMENT */, texture: { sampleType: "float" }, }, + { + binding: 5, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "uint" }, + }, + { + binding: 6, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "uint" }, + }, ], }); - this.pipeline = device.createRenderPipeline({ - layout: device.createPipelineLayout({ - bindGroupLayouts: [this.bindGroupLayout], - }), - vertex: { module: shaderModule, entryPoint: "vsMain" }, - fragment: { - module: shaderModule, - entryPoint: "fsMain", - targets: [{ format: canvasFormat }], - }, - primitive: { topology: "triangle-list" }, - }); + await this.setShader(this.shaderPath); this.rebuildBindGroup(); @@ -84,6 +81,30 @@ export class TerritoryRenderPass implements RenderPass { this.clearB = bg.b / 255; } + async setShader(shaderPath: string): Promise { + this.shaderPath = shaderPath; + + if (!this.device || !this.bindGroupLayout || !this.canvasFormat) { + return; + } + + const shaderCode = await loadShader(shaderPath); + const shaderModule = this.device.createShaderModule({ code: shaderCode }); + + this.pipeline = this.device.createRenderPipeline({ + layout: this.device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + vertex: { module: shaderModule, entryPoint: "vsMain" }, + fragment: { + module: shaderModule, + entryPoint: "fsMain", + targets: [{ format: this.canvasFormat }], + }, + primitive: { topology: "triangle-list" }, + }); + } + needsUpdate(): boolean { // Always run every frame (can be optimized later if needed) return true; @@ -139,7 +160,9 @@ export class TerritoryRenderPass implements RenderPass { !this.resources.stateTexture || !this.resources.defendedStrengthTexture || !this.resources.paletteTexture || - !this.resources.terrainTexture + !this.resources.terrainTexture || + !this.resources.ownerIndexTexture || + !this.resources.relationsTexture ) { return; } @@ -164,6 +187,14 @@ export class TerritoryRenderPass implements RenderPass { binding: 4, resource: this.resources.terrainTexture.createView(), }, + { + binding: 5, + resource: this.resources.ownerIndexTexture.createView(), + }, + { + binding: 6, + resource: this.resources.relationsTexture.createView(), + }, ], }); } diff --git a/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts b/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts new file mode 100644 index 000000000..183993fa2 --- /dev/null +++ b/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts @@ -0,0 +1,353 @@ +export type TerritoryShaderId = "classic" | "retro"; + +export type TerritoryShaderOption = + | { + kind: "boolean"; + key: string; + label: string; + defaultValue: boolean; + } + | { + kind: "range"; + key: string; + label: string; + defaultValue: number; + min: number; + max: number; + step: number; + } + | { + kind: "enum"; + key: string; + label: string; + defaultValue: number; + options: Array<{ value: number; label: string }>; + }; + +export interface TerritoryShaderDefinition { + id: TerritoryShaderId; + label: string; + wgslPath: string; + options: TerritoryShaderOption[]; +} + +export const TERRITORY_SHADER_KEY = "settings.webgpu.territory.shader"; + +export const TERRITORY_SHADERS: TerritoryShaderDefinition[] = [ + { + id: "classic", + label: "Simple", + wgslPath: "render/territory.wgsl", + options: [ + { + kind: "enum", + key: "settings.webgpu.territory.classic.borderMode", + label: "Border Mode", + defaultValue: 1, + options: [ + { value: 0, label: "Off" }, + { value: 1, label: "Simple" }, + { value: 2, label: "Glow" }, + ], + }, + { + kind: "range", + key: "settings.webgpu.territory.classic.thicknessPx", + label: "Thickness (px)", + defaultValue: 1, + min: 0.5, + max: 8, + step: 0.5, + }, + { + kind: "range", + key: "settings.webgpu.territory.classic.borderStrength", + label: "Border Strength", + defaultValue: 0.64, + min: 0, + max: 1, + step: 0.01, + }, + { + kind: "range", + key: "settings.webgpu.territory.classic.glowStrength", + label: "Glow Strength", + defaultValue: 0.42, + min: 0, + max: 1, + step: 0.01, + }, + { + kind: "range", + key: "settings.webgpu.territory.classic.glowRadiusMul", + label: "Glow Radius", + defaultValue: 1, + min: 1, + max: 12, + step: 0.25, + }, + { + kind: "boolean", + key: "settings.webgpu.territory.classic.drawDefendedRadius", + label: "Draw Defended Radius", + defaultValue: false, + }, + { + kind: "boolean", + key: "settings.webgpu.territory.classic.disableDefendedTint", + label: "Disable Defended Tint", + defaultValue: false, + }, + ], + }, + { + id: "retro", + label: "Retro", + wgslPath: "render/retro.wgsl", + options: [ + { + kind: "boolean", + key: "settings.webgpu.territory.retro.colorByRelations", + label: "Color By Player Relations", + defaultValue: true, + }, + { + kind: "boolean", + key: "settings.webgpu.territory.retro.patternWhenDefended", + label: "Pattern When In Defended Range", + defaultValue: true, + }, + { + kind: "boolean", + key: "settings.webgpu.territory.retro.splitBorder", + label: "Split Border", + defaultValue: true, + }, + { + kind: "boolean", + key: "settings.webgpu.territory.retro.drawDefendedRadius", + label: "Draw Defended Radius", + defaultValue: true, + }, + { + kind: "boolean", + key: "settings.webgpu.territory.retro.disableDefendedTint", + label: "Disable Defended Tint", + defaultValue: true, + }, + { + kind: "range", + key: "settings.webgpu.territory.retro.thicknessPx", + label: "Thickness (px)", + defaultValue: 6, + min: 0.5, + max: 12, + step: 0.5, + }, + { + kind: "range", + key: "settings.webgpu.territory.retro.borderStrength", + label: "Border Strength", + defaultValue: 1, + min: 0, + max: 1, + step: 0.01, + }, + { + kind: "range", + key: "settings.webgpu.territory.retro.glowStrength", + label: "Glow Strength", + defaultValue: 0, + min: 0, + max: 1, + step: 0.01, + }, + { + kind: "range", + key: "settings.webgpu.territory.retro.glowRadiusMul", + label: "Glow Radius", + defaultValue: 1, + min: 1, + max: 16, + step: 0.25, + }, + { + kind: "range", + key: "settings.webgpu.territory.retro.relationTintStrength", + label: "Relation Tint Strength", + defaultValue: 1, + min: 0, + max: 1, + step: 0.01, + }, + { + kind: "range", + key: "settings.webgpu.territory.retro.defendedPatternStrength", + label: "Defended Pattern Strength", + defaultValue: 0.5, + min: 0, + max: 1, + step: 0.01, + }, + { + kind: "range", + key: "settings.webgpu.territory.retro.defendedThreshold", + label: "Defended Threshold", + defaultValue: 0.01, + min: 0, + max: 1, + step: 0.01, + }, + ], + }, +]; + +export function getTerritoryShaderById( + id: TerritoryShaderId, +): TerritoryShaderDefinition { + const found = TERRITORY_SHADERS.find((s) => s.id === id); + if (!found) { + throw new Error(`Unknown territory shader: ${id}`); + } + return found; +} + +export function territoryShaderIdFromInt(value: number): TerritoryShaderId { + return value === 1 ? "retro" : "classic"; +} + +export function territoryShaderIntFromId(id: TerritoryShaderId): number { + return id === "retro" ? 1 : 0; +} + +export function readTerritoryShaderId(userSettings: { + getInt: (key: string, defaultValue: number) => number; +}): TerritoryShaderId { + return territoryShaderIdFromInt(userSettings.getInt(TERRITORY_SHADER_KEY, 0)); +} + +export function buildTerritoryShaderParams( + userSettings: { + get: (key: string, defaultValue: boolean) => boolean; + getFloat: (key: string, defaultValue: number) => number; + getInt: (key: string, defaultValue: number) => number; + }, + shaderId: TerritoryShaderId, +): { shaderPath: string; params0: Float32Array; params1: Float32Array } { + if (shaderId === "retro") { + const thicknessPx = userSettings.getFloat( + "settings.webgpu.territory.retro.thicknessPx", + 6, + ); + const borderStrength = userSettings.getFloat( + "settings.webgpu.territory.retro.borderStrength", + 1, + ); + const glowStrength = userSettings.getFloat( + "settings.webgpu.territory.retro.glowStrength", + 0, + ); + const glowRadiusMul = userSettings.getFloat( + "settings.webgpu.territory.retro.glowRadiusMul", + 1, + ); + + const colorByRelations = userSettings.get( + "settings.webgpu.territory.retro.colorByRelations", + true, + ); + const patternWhenDefended = userSettings.get( + "settings.webgpu.territory.retro.patternWhenDefended", + true, + ); + const splitBorder = userSettings.get( + "settings.webgpu.territory.retro.splitBorder", + true, + ); + const drawDefendedRadius = userSettings.get( + "settings.webgpu.territory.retro.drawDefendedRadius", + true, + ); + const disableDefendedTint = userSettings.get( + "settings.webgpu.territory.retro.disableDefendedTint", + true, + ); + const relationTintStrength = userSettings.getFloat( + "settings.webgpu.territory.retro.relationTintStrength", + 1, + ); + const defendedPatternStrength = userSettings.getFloat( + "settings.webgpu.territory.retro.defendedPatternStrength", + 0.5, + ); + const defendedThreshold = userSettings.getFloat( + "settings.webgpu.territory.retro.defendedThreshold", + 0.01, + ); + + let flags = 0; + if (colorByRelations) flags |= 1 << 0; + if (patternWhenDefended) flags |= 1 << 1; + if (splitBorder) flags |= 1 << 2; + if (drawDefendedRadius) flags |= 1 << 3; + if (disableDefendedTint) flags |= 1 << 4; + + const params0 = new Float32Array([ + thicknessPx, + borderStrength, + glowStrength, + glowRadiusMul, + ]); + const params1 = new Float32Array([ + flags, + relationTintStrength, + defendedPatternStrength, + defendedThreshold, + ]); + + return { shaderPath: "render/retro.wgsl", params0, params1 }; + } + + const borderMode = userSettings.getInt( + "settings.webgpu.territory.classic.borderMode", + 1, + ); + const thicknessPx = userSettings.getFloat( + "settings.webgpu.territory.classic.thicknessPx", + 1, + ); + const borderStrength = userSettings.getFloat( + "settings.webgpu.territory.classic.borderStrength", + 0.64, + ); + const glowStrength = userSettings.getFloat( + "settings.webgpu.territory.classic.glowStrength", + 0.42, + ); + const glowRadiusMul = userSettings.getFloat( + "settings.webgpu.territory.classic.glowRadiusMul", + 1, + ); + const drawDefendedRadius = userSettings.get( + "settings.webgpu.territory.classic.drawDefendedRadius", + false, + ); + const disableDefendedTint = userSettings.get( + "settings.webgpu.territory.classic.disableDefendedTint", + false, + ); + + const params0 = new Float32Array([ + borderMode, + thicknessPx, + borderStrength, + glowStrength, + ]); + const params1 = new Float32Array([ + glowRadiusMul, + drawDefendedRadius ? 1 : 0, + disableDefendedTint ? 1 : 0, + 0, + ]); + return { shaderPath: "render/territory.wgsl", params0, params1 }; +} diff --git a/src/client/graphics/webgpu/shaders/render/retro.wgsl b/src/client/graphics/webgpu/shaders/render/retro.wgsl new file mode 100644 index 000000000..ad257d504 --- /dev/null +++ b/src/client/graphics/webgpu/shaders/render/retro.wgsl @@ -0,0 +1,303 @@ +struct Uniforms { + mapResolution_viewScale_time: vec4f, // x=mapW, y=mapH, z=viewScale, w=timeSec + viewOffset_alt_highlight: vec4f, // x=offX, y=offY, z=alternativeView, w=highlightOwnerId + viewSize_pad: vec4f, // x=viewW, y=viewH, z=myPlayerSmallId, w unused + shaderParams0: vec4f, // x=thicknessPx, y=borderStrength, z=glowStrength, w=glowRadiusMul + shaderParams1: vec4f, // x=flags, y=relationTintStrength, z=defendedPatternStrength, w=defendedThreshold +}; + +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var stateTex: texture_2d; +@group(0) @binding(2) var defendedStrengthTex: texture_2d; +@group(0) @binding(3) var paletteTex: texture_2d; +@group(0) @binding(4) var terrainTex: texture_2d; +@group(0) @binding(5) var ownerIndexTex: texture_2d; +@group(0) @binding(6) var relationsTex: texture_2d; + +@vertex +fn vsMain(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f { + var pos = array( + vec2f(-1.0, -1.0), + vec2f(3.0, -1.0), + vec2f(-1.0, 3.0), + ); + let p = pos[vi]; + return vec4f(p, 0.0, 1.0); +} + +fn hasFlag(flags: u32, bit: u32) -> bool { + return (flags & (1u << bit)) != 0u; +} + +fn relationCode(ownerA: u32, ownerB: u32) -> u32 { + if (ownerA == 0u || ownerB == 0u) { + return 0u; + } + let aDense = textureLoad(ownerIndexTex, vec2i(i32(ownerA), 0), 0).x; + let bDense = textureLoad(ownerIndexTex, vec2i(i32(ownerB), 0), 0).x; + if (aDense == 0u || bDense == 0u) { + return 0u; + } + return textureLoad(relationsTex, vec2i(i32(aDense), i32(bDense)), 0).x; +} + +fn applyDefendedPattern( + baseRgb: vec3f, + strength: f32, + texCoord: vec2i, +) -> vec3f { + let parity = (u32(texCoord.x) ^ u32(texCoord.y)) & 1u; + let factor = select(0.75, 1.25, parity == 1u); + let patterned = clamp(baseRgb * factor, vec3f(0.0), vec3f(1.0)); + return mix(baseRgb, patterned, clamp(strength, 0.0, 1.0)); +} + +@fragment +fn fsMain(@builtin(position) pos: vec4f) -> @location(0) vec4f { + let mapRes = u.mapResolution_viewScale_time.xy; + let viewScale = u.mapResolution_viewScale_time.z; + let timeSec = u.mapResolution_viewScale_time.w; + let viewOffset = u.viewOffset_alt_highlight.xy; + let altView = u.viewOffset_alt_highlight.z; + let highlightId = u.viewOffset_alt_highlight.w; + let myPlayerSmallId = u.viewSize_pad.z; + + let thicknessPx = u.shaderParams0.x; + let borderStrength = u.shaderParams0.y; + let glowStrength = u.shaderParams0.z; + let glowRadiusMul = u.shaderParams0.w; + + let flags = u32(max(0.0, u.shaderParams1.x) + 0.5); + let relationTintStrength = u.shaderParams1.y; + let defendedPatternStrength = u.shaderParams1.z; + let defendedThreshold = u.shaderParams1.w; + + let enableRelations = hasFlag(flags, 0u); + let enableDefendedPattern = hasFlag(flags, 1u); + let enableSplit = hasFlag(flags, 2u); + let drawDefendedRadius = hasFlag(flags, 3u); + let disableDefendedTint = hasFlag(flags, 4u); + + // WebGPU fragment position is top-left origin and at pixel centers (0.5, 1.5, ...). + let viewCoord = vec2f(pos.x - 0.5, pos.y - 0.5); + let mapHalf = mapRes * 0.5; + let mapCoord = (viewCoord - mapHalf) / viewScale + viewOffset + mapHalf; + + if ( + mapCoord.x < 0.0 || + mapCoord.y < 0.0 || + mapCoord.x >= mapRes.x || + mapCoord.y >= mapRes.y + ) { + discard; + } + + let texCoord = vec2i(mapCoord); + let state = textureLoad(stateTex, texCoord, 0).x; + let owner = state & 0xFFFu; + let hasFallout = (state & 0x2000u) != 0u; + + let terrain = textureLoad(terrainTex, texCoord, 0); + let defendedStrength = textureLoad(defendedStrengthTex, texCoord, 0).x; + + var outColor = terrain; + if (owner != 0u) { + // Player colors start at index 10 + let c = textureLoad(paletteTex, vec2i(i32(owner) + 10, 0), 0); + var territoryRgb = c.rgb; + if (!disableDefendedTint) { + let defendedTint = select( + 0.0, + clamp(0.8 * defendedStrength, 0.1, 0.35), + defendedStrength > 0.001, + ); + territoryRgb = mix( + territoryRgb, + vec3f(1.0, 0.0, 1.0), + defendedTint, + ); + } + if (hasFallout) { + // Fallout color is at index 0 + let falloutColor = textureLoad(paletteTex, vec2i(0, 0), 0).rgb; + territoryRgb = mix(territoryRgb, falloutColor, 0.5); + } + outColor = vec4f(mix(terrain.rgb, territoryRgb, 0.65), 1.0); + } else if (hasFallout) { + let falloutColor = textureLoad(paletteTex, vec2i(0, 0), 0).rgb; + outColor = vec4f(mix(terrain.rgb, falloutColor, 0.5), 1.0); + } + + // In alt view we show only borders on top of terrain. + if (altView > 0.5) { + outColor = terrain; + } + + if (owner != 0u) { + let fx = fract(mapCoord.x); + let fy = fract(mapCoord.y); + + var bestDist = 1e9; + var otherOwner = 0u; + var otherCoord = texCoord; + + // Only border against other non-zero owners. + if (texCoord.x > 0) { + let o = textureLoad(stateTex, texCoord + vec2i(-1, 0), 0).x & 0xFFFu; + if (o != 0u && o != owner) { + let d = fx; + if (d < bestDist) { + bestDist = d; + otherOwner = o; + otherCoord = texCoord + vec2i(-1, 0); + } + } + } + if (texCoord.x + 1 < i32(mapRes.x)) { + let o = textureLoad(stateTex, texCoord + vec2i(1, 0), 0).x & 0xFFFu; + if (o != 0u && o != owner) { + let d = 1.0 - fx; + if (d < bestDist) { + bestDist = d; + otherOwner = o; + otherCoord = texCoord + vec2i(1, 0); + } + } + } + if (texCoord.y > 0) { + let o = textureLoad(stateTex, texCoord + vec2i(0, -1), 0).x & 0xFFFu; + if (o != 0u && o != owner) { + let d = fy; + if (d < bestDist) { + bestDist = d; + otherOwner = o; + otherCoord = texCoord + vec2i(0, -1); + } + } + } + if (texCoord.y + 1 < i32(mapRes.y)) { + let o = textureLoad(stateTex, texCoord + vec2i(0, 1), 0).x & 0xFFFu; + if (o != 0u && o != owner) { + let d = 1.0 - fy; + if (d < bestDist) { + bestDist = d; + otherOwner = o; + otherCoord = texCoord + vec2i(0, 1); + } + } + } + + if (otherOwner != 0u) { + let pxPerTile = max(viewScale, 0.001); + let aaTiles = 1.0 / pxPerTile; + let thicknessTiles = max(0.1, thicknessPx) / pxPerTile; + + let line = 1.0 - smoothstep(thicknessTiles, thicknessTiles + aaTiles, bestDist); + let glowTiles = (max(0.1, thicknessPx) * max(0.1, glowRadiusMul)) / pxPerTile; + let glow = 1.0 - smoothstep(glowTiles, glowTiles + aaTiles * 3.0, bestDist); + + var baseBorderRgb = textureLoad(paletteTex, vec2i(i32(owner) + 10, 1), 0).rgb; + + if (!enableSplit) { + let otherBorderRgb = textureLoad(paletteTex, vec2i(i32(otherOwner) + 10, 1), 0).rgb; + baseBorderRgb = 0.5 * (baseBorderRgb + otherBorderRgb); + } + + var edgeDefendedStrength = defendedStrength; + if (!enableSplit) { + let otherDef = textureLoad(defendedStrengthTex, otherCoord, 0).x; + edgeDefendedStrength = max(edgeDefendedStrength, otherDef); + } + + // Determine relation color (normal: between owners, altView: relation to viewer). + var rel = 0u; + if (enableRelations) { + if (altView > 0.5) { + rel = relationCode(owner, u32(max(0.0, myPlayerSmallId) + 0.5)); + } else { + rel = relationCode(owner, otherOwner); + } + } + + var borderRgb = baseBorderRgb; + if (rel != 0u) { + let tintTarget = select(vec3f(0.0, 1.0, 0.0), vec3f(1.0, 0.0, 0.0), rel == 2u); + let tint = clamp(0.35 * relationTintStrength, 0.0, 1.0); + borderRgb = mix(borderRgb, tintTarget, tint); + } + + if (enableDefendedPattern && edgeDefendedStrength >= defendedThreshold) { + borderRgb = applyDefendedPattern(borderRgb, defendedPatternStrength, texCoord); + } + + outColor = vec4f( + mix(outColor.rgb, borderRgb, clamp(line * borderStrength, 0.0, 1.0)), + outColor.a, + ); + outColor = vec4f( + mix(outColor.rgb, borderRgb, clamp(glow * glowStrength, 0.0, 1.0)), + outColor.a, + ); + } + } + + if (drawDefendedRadius && defendedStrength > 0.001 && owner != 0u) { + let fx = fract(mapCoord.x); + let fy = fract(mapCoord.y); + + var dist = 1e9; + + if (texCoord.x > 0) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(-1, 0), 0).x; + if (s <= 0.001) { + dist = min(dist, fx); + } + } + if (texCoord.x + 1 < i32(mapRes.x)) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(1, 0), 0).x; + if (s <= 0.001) { + dist = min(dist, 1.0 - fx); + } + } + if (texCoord.y > 0) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(0, -1), 0).x; + if (s <= 0.001) { + dist = min(dist, fy); + } + } + if (texCoord.y + 1 < i32(mapRes.y)) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(0, 1), 0).x; + if (s <= 0.001) { + dist = min(dist, 1.0 - fy); + } + } + + if (dist < 1e8) { + let pxPerTile = max(viewScale, 0.001); + let aaTiles = 1.0 / pxPerTile; + let thicknessTiles = 1.5 / pxPerTile; + let line = 1.0 - smoothstep(thicknessTiles, thicknessTiles + aaTiles, dist); + + let baseBorderRgb = textureLoad(paletteTex, vec2i(i32(owner) + 10, 1), 0).rgb; + let ringRgb = mix(baseBorderRgb, vec3f(1.0, 1.0, 1.0), 0.5); + outColor = vec4f( + mix(outColor.rgb, ringRgb, clamp(line * 0.65, 0.0, 1.0)), + outColor.a, + ); + } + } + + // Apply hover highlight if needed + if (highlightId > 0.5) { + let alpha = select(0.65, 0.0, altView > 0.5); + + if (alpha > 0.0 && owner != 0u && abs(f32(owner) - highlightId) < 0.5) { + let pulse = 0.5 + 0.5 * sin(timeSec * 6.2831853); + let strength = 0.15 + 0.15 * pulse; + let highlightedRgb = mix(outColor.rgb, vec3f(1.0, 1.0, 1.0), strength); + outColor = vec4f(highlightedRgb, outColor.a); + } + } + + return outColor; +} diff --git a/src/client/graphics/webgpu/shaders/render/territory.wgsl b/src/client/graphics/webgpu/shaders/render/territory.wgsl index 9f89903c7..a234c5a63 100644 --- a/src/client/graphics/webgpu/shaders/render/territory.wgsl +++ b/src/client/graphics/webgpu/shaders/render/territory.wgsl @@ -1,7 +1,9 @@ struct Uniforms { mapResolution_viewScale_time: vec4f, // x=mapW, y=mapH, z=viewScale, w=timeSec viewOffset_alt_highlight: vec4f, // x=offX, y=offY, z=alternativeView, w=highlightOwnerId - viewSize_pad: vec4f, // x=viewW, y=viewH, z=borderMode, w unused + viewSize_pad: vec4f, // x=viewW, y=viewH, z=myPlayerSmallId, w unused + shaderParams0: vec4f, + shaderParams1: vec4f, }; @group(0) @binding(0) var u: Uniforms; @@ -9,6 +11,8 @@ struct Uniforms { @group(0) @binding(2) var defendedStrengthTex: texture_2d; @group(0) @binding(3) var paletteTex: texture_2d; @group(0) @binding(4) var terrainTex: texture_2d; +@group(0) @binding(5) var ownerIndexTex: texture_2d; +@group(0) @binding(6) var relationsTex: texture_2d; @vertex fn vsMain(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f { @@ -30,7 +34,13 @@ fn fsMain(@builtin(position) pos: vec4f) -> @location(0) vec4f { let altView = u.viewOffset_alt_highlight.z; let highlightId = u.viewOffset_alt_highlight.w; let viewSize = u.viewSize_pad.xy; - let borderMode = u.viewSize_pad.z; + let borderMode = u.shaderParams0.x; + let thicknessPx = u.shaderParams0.y; + let borderStrength = u.shaderParams0.z; + let glowStrength = u.shaderParams0.w; + let glowRadiusMul = u.shaderParams1.x; + let drawDefendedRadius = u.shaderParams1.y; + let disableDefendedTint = u.shaderParams1.z; // WebGPU fragment position is top-left origin and at pixel centers (0.5, 1.5, ...). let viewCoord = vec2f(pos.x - 0.5, pos.y - 0.5); @@ -55,11 +65,18 @@ fn fsMain(@builtin(position) pos: vec4f) -> @location(0) vec4f { // Player colors start at index 10 let c = textureLoad(paletteTex, vec2i(i32(owner) + 10, 0), 0); var territoryRgb = c.rgb; - territoryRgb = mix( - territoryRgb, - vec3f(1.0, 0.0, 1.0), - clamp(0.8 * defendedStrength, 0.1, 0.35), - ); + if (disableDefendedTint <= 0.5) { + let defendedTint = select( + 0.0, + clamp(0.8 * defendedStrength, 0.1, 0.35), + defendedStrength > 0.001, + ); + territoryRgb = mix( + territoryRgb, + vec3f(1.0, 0.0, 1.0), + defendedTint, + ); + } if (hasFallout) { // Fallout color is at index 0 let falloutColor = textureLoad(paletteTex, vec2i(0, 0), 0).rgb; @@ -117,28 +134,74 @@ fn fsMain(@builtin(position) pos: vec4f) -> @location(0) vec4f { // Mode 1: thin black border. // Mode 2: thicker black border + obvious tinted glow. let isGlow = borderMode > 1.5; - let thicknessPx = select(1.0, 3.0, isGlow); let thicknessTiles = thicknessPx / pxPerTile; let line = 1.0 - smoothstep(thicknessTiles, thicknessTiles + aaTiles, dist); outColor = vec4f( - mix(outColor.rgb, vec3f(0.0, 0.0, 0.0), clamp(line * 0.95, 0.0, 0.95)), + mix(outColor.rgb, vec3f(0.0, 0.0, 0.0), clamp(line * borderStrength, 0.0, 1.0)), outColor.a, ); if (isGlow) { - let glowTiles = (thicknessPx * 5.0) / pxPerTile; + let glowTiles = (thicknessPx * glowRadiusMul) / pxPerTile; let glow = 1.0 - smoothstep(glowTiles, glowTiles + aaTiles * 3.0, dist); let ownerRgb = textureLoad(paletteTex, vec2i(i32(owner) + 10, 0), 0).rgb; let glowColor = mix(vec3f(1.0, 1.0, 1.0), ownerRgb, 0.85); outColor = vec4f( - mix(outColor.rgb, glowColor, clamp(glow * 0.35, 0.0, 0.35)), + mix(outColor.rgb, glowColor, clamp(glow * glowStrength, 0.0, 1.0)), outColor.a, ); } } } + // Debug: defended radius boundary (based on defendedStrengthTex coverage). + if (drawDefendedRadius > 0.5 && defendedStrength > 0.001 && owner != 0u) { + let fx = fract(mapCoord.x); + let fy = fract(mapCoord.y); + + var dist = 1e9; + + if (texCoord.x > 0) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(-1, 0), 0).x; + if (s <= 0.001) { + dist = min(dist, fx); + } + } + if (texCoord.x + 1 < i32(mapRes.x)) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(1, 0), 0).x; + if (s <= 0.001) { + dist = min(dist, 1.0 - fx); + } + } + if (texCoord.y > 0) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(0, -1), 0).x; + if (s <= 0.001) { + dist = min(dist, fy); + } + } + if (texCoord.y + 1 < i32(mapRes.y)) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(0, 1), 0).x; + if (s <= 0.001) { + dist = min(dist, 1.0 - fy); + } + } + + if (dist < 1e8) { + let pxPerTile = max(viewScale, 0.001); + let aaTiles = 1.0 / pxPerTile; + let thicknessTiles = 1.5 / pxPerTile; + let line = 1.0 - smoothstep(thicknessTiles, thicknessTiles + aaTiles, dist); + + let borderRgb = textureLoad(paletteTex, vec2i(i32(owner) + 10, 1), 0).rgb; + let ringRgb = mix(borderRgb, vec3f(1.0, 1.0, 1.0), 0.5); + outColor = vec4f( + mix(outColor.rgb, ringRgb, clamp(line * 0.65, 0.0, 1.0)), + outColor.a, + ); + } + } + // Apply hover highlight if needed if (highlightId > 0.5) { let alpha = select(0.65, 0.0, altView > 0.5); diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index 74796e151..83d7e7710 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -55,6 +55,10 @@ export class UserSettings { return this.get("settings.performanceOverlay", false); } + webgpuDebug(): boolean { + return this.get("settings.webgpuDebug", true); + } + alertFrame() { return this.get("settings.alertFrame", true); } @@ -87,10 +91,6 @@ export class UserSettings { return this.get("settings.territoryPatterns", true); } - territoryBorderMode(): number { - return this.getInt("settings.territoryBorderMode", 1); - } - cursorCostLabel() { const legacy = this.get("settings.ghostPricePill", true); return this.get("settings.cursorCostLabel", legacy); @@ -118,6 +118,10 @@ export class UserSettings { this.set("settings.performanceOverlay", !this.performanceOverlay()); } + toggleWebgpuDebug() { + this.set("settings.webgpuDebug", !this.webgpuDebug()); + } + toggleAlertFrame() { this.set("settings.alertFrame", !this.alertFrame()); }