Files
OpenFrontIO/src/client/graphics/layers/WebGPUDebugOverlay.ts
T
2026-02-05 21:46:49 +01:00

524 lines
15 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.
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { live } from "lit/directives/live.js";
import { EventBus } from "../../../core/EventBus";
import { UserSettings } from "../../../core/game/UserSettings";
import {
RefreshGraphicsEvent,
WebGPUComputeMetricsEvent,
} from "../../InputHandler";
import {
TERRAIN_SHADER_KEY,
TERRAIN_SHADERS,
terrainShaderIdFromInt,
terrainShaderIntFromId,
TerrainShaderOption,
} from "../webgpu/render/TerrainShaderRegistry";
import {
TERRITORY_POST_SMOOTHING,
TERRITORY_POST_SMOOTHING_KEY,
territoryPostSmoothingIdFromInt,
territoryPostSmoothingIntFromId,
} from "../webgpu/render/TerritoryPostSmoothingRegistry";
import {
TERRITORY_PRE_SMOOTHING,
TERRITORY_PRE_SMOOTHING_KEY,
territoryPreSmoothingIdFromInt,
territoryPreSmoothingIntFromId,
} from "../webgpu/render/TerritoryPreSmoothingRegistry";
import {
TERRITORY_SHADER_KEY,
TERRITORY_SHADERS,
territoryShaderIdFromInt,
territoryShaderIntFromId,
TerritoryShaderOption,
} from "../webgpu/render/TerritoryShaderRegistry";
import { Layer } from "./Layer";
type ShaderOption = TerrainShaderOption | TerritoryShaderOption;
@customElement("webgpu-debug-overlay")
export class WebGPUDebugOverlay extends LitElement implements Layer {
@property({ type: Object })
public eventBus!: EventBus;
@property({ type: Object })
public userSettings!: UserSettings;
@state()
private renderFps: number = 0;
@state()
private tickComputeMs: number = 0;
private frameTimes: number[] = [];
private lastDebugEnabled: boolean | null = null;
static styles = css`
.overlay {
position: fixed;
top: 16px;
left: 16px;
z-index: 9999;
min-width: 340px;
max-width: 420px;
background: rgba(0, 0, 0, 0.82);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
padding: 10px 12px;
color: rgba(255, 255, 255, 0.92);
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
font-size: 12px;
pointer-events: auto;
user-select: none;
}
.title {
font-weight: 700;
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.titleLeft {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.closeButton {
appearance: none;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.75);
font-size: 16px;
line-height: 16px;
padding: 2px 6px;
border-radius: 6px;
cursor: pointer;
}
.closeButton:hover {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.95);
}
.metrics {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 10px;
margin-bottom: 10px;
}
.metric {
display: flex;
justify-content: space-between;
gap: 10px;
white-space: nowrap;
}
.label {
color: rgba(255, 255, 255, 0.7);
}
.value {
color: rgba(255, 255, 255, 0.95);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: 6px 0;
user-select: none;
}
.sectionTitle {
margin-top: 10px;
font-weight: 700;
letter-spacing: 0.02em;
color: rgba(255, 255, 255, 0.85);
text-transform: uppercase;
font-size: 11px;
}
select,
input[type="range"] {
width: 170px;
}
select {
background: rgba(0, 0, 0, 0.6);
color: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
padding: 4px 6px;
font-size: 12px;
}
input[type="checkbox"] {
transform: translateY(1px);
}
.range {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 10px;
}
.rangeValue {
min-width: 54px;
text-align: right;
color: rgba(255, 255, 255, 0.8);
font-variant-numeric: tabular-nums;
}
`;
init() {
this.eventBus.on(WebGPUComputeMetricsEvent, (e) => {
this.syncDebugVisibility();
if (typeof e.computeMs === "number" && Number.isFinite(e.computeMs)) {
this.tickComputeMs = e.computeMs;
this.requestUpdate();
}
});
this.requestUpdate();
}
private syncDebugVisibility(): boolean {
const enabled = !!this.userSettings?.webgpuDebug();
if (this.lastDebugEnabled !== enabled) {
this.lastDebugEnabled = enabled;
this.requestUpdate();
}
return enabled;
}
updateFrameMetrics(frameDurationMs: number): void {
if (!this.syncDebugVisibility()) {
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 close(): void {
if (!this.userSettings) {
return;
}
this.userSettings.set("settings.webgpuDebug", false);
this.syncDebugVisibility();
}
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 selectedTerrainShaderId() {
const selected = this.userSettings.getInt(TERRAIN_SHADER_KEY, 0);
return terrainShaderIdFromInt(selected);
}
private setSelectedTerrainShaderId(
id: "classic" | "improved-lite" | "improved-heavy",
) {
this.userSettings.setInt(TERRAIN_SHADER_KEY, terrainShaderIntFromId(id));
this.requestUpdate();
}
private selectedPreSmoothingId() {
const selected = this.userSettings.getInt(TERRITORY_PRE_SMOOTHING_KEY, 0);
return territoryPreSmoothingIdFromInt(selected);
}
private setSelectedPreSmoothingId(id: "off" | "dissolve" | "budget") {
this.userSettings.setInt(
TERRITORY_PRE_SMOOTHING_KEY,
territoryPreSmoothingIntFromId(id),
);
this.requestUpdate();
}
private selectedPostSmoothingId() {
const selected = this.userSettings.getInt(TERRITORY_POST_SMOOTHING_KEY, 0);
return territoryPostSmoothingIdFromInt(selected);
}
private setSelectedPostSmoothingId(id: "off" | "fade" | "dissolve") {
this.userSettings.setInt(
TERRITORY_POST_SMOOTHING_KEY,
territoryPostSmoothingIntFromId(id),
);
this.requestUpdate();
}
private renderOptionControl(option: ShaderOption) {
if (option.kind === "boolean") {
const enabled = this.userSettings.get(option.key, option.defaultValue);
return html`
<div class="row">
<div class="label">${option.label}</div>
<input
type="checkbox"
.checked=${live(enabled)}
@change=${(e: Event) => {
const checked = (e.target as HTMLInputElement).checked;
this.userSettings.set(option.key, checked);
this.requestUpdate();
}}
/>
</div>
`;
}
if (option.kind === "enum") {
const value = this.userSettings.getInt(option.key, option.defaultValue);
return html`
<div class="row">
<div class="label">${option.label}</div>
<select
.value=${live(String(value))}
@change=${(e: Event) => {
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`<option value=${String(o.value)}>${o.label}</option>`,
)}
</select>
</div>
`;
}
const value = this.userSettings.getFloat(option.key, option.defaultValue);
return html`
<div class="row">
<div class="label">${option.label}</div>
<div class="range">
<input
type="range"
min=${String(option.min)}
max=${String(option.max)}
step=${String(option.step)}
.value=${live(String(value))}
@input=${(e: Event) => {
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();
}}
/>
<div class="rangeValue">${value.toFixed(2)}</div>
</div>
</div>
`;
}
render() {
if (!this.userSettings || !this.userSettings.webgpuDebug()) {
return null;
}
const backgroundRenderer = this.userSettings.backgroundRenderer();
const shaderId = this.selectedShaderId();
const shader =
TERRITORY_SHADERS.find((s) => s.id === shaderId) ?? TERRITORY_SHADERS[0];
const terrainShaderId = this.selectedTerrainShaderId();
const terrainShader =
TERRAIN_SHADERS.find((s) => s.id === terrainShaderId) ??
TERRAIN_SHADERS[0];
const preId = this.selectedPreSmoothingId();
const pre =
TERRITORY_PRE_SMOOTHING.find((s) => s.id === preId) ??
TERRITORY_PRE_SMOOTHING[0];
const postId = this.selectedPostSmoothingId();
const post =
TERRITORY_POST_SMOOTHING.find((s) => s.id === postId) ??
TERRITORY_POST_SMOOTHING[0];
return html`
<div class="overlay">
<div class="title">
<div class="titleLeft">WebGPU Debug</div>
<button
class="closeButton"
title="Close"
aria-label="Close WebGPU Debug overlay"
@click=${() => this.close()}
>
×
</button>
</div>
<div class="sectionTitle">Renderer</div>
<div class="row">
<div class="label">Background</div>
<select
.value=${live(backgroundRenderer)}
@change=${(e: Event) => {
const raw = (e.target as HTMLSelectElement).value;
const next =
raw === "canvas2d"
? ("canvas2d" as const)
: ("webgpu" as const);
if (next === this.userSettings.backgroundRenderer()) {
return;
}
this.userSettings.setBackgroundRenderer(next);
this.eventBus.emit(new RefreshGraphicsEvent());
this.requestUpdate();
}}
>
<option value="webgpu">WebGPU (worker)</option>
<option value="canvas2d">Canvas2D (worker)</option>
</select>
</div>
<div class="metrics">
<div class="metric">
<div class="label">tick ms compute</div>
<div class="value">${this.tickComputeMs.toFixed(2)}</div>
</div>
<div class="metric">
<div class="label">render fps</div>
<div class="value">${this.renderFps}</div>
</div>
</div>
<div class="sectionTitle">Terrain</div>
<div class="row">
<div class="label">Terrain Shader</div>
<select
.value=${live(String(terrainShaderIntFromId(terrainShaderId)))}
@change=${(e: Event) => {
const raw = (e.target as HTMLSelectElement).value;
const next = terrainShaderIdFromInt(Number.parseInt(raw, 10));
this.setSelectedTerrainShaderId(next);
}}
>
${TERRAIN_SHADERS.map(
(s) =>
html`<option value=${String(terrainShaderIntFromId(s.id))}>
${s.label}
</option>`,
)}
</select>
</div>
${terrainShader.options.map((opt) => this.renderOptionControl(opt))}
<div class="sectionTitle">Territory</div>
<div class="row">
<div class="label">Territory Shader</div>
<select
.value=${live(String(territoryShaderIntFromId(shaderId)))}
@change=${(e: Event) => {
const raw = (e.target as HTMLSelectElement).value;
const next = territoryShaderIdFromInt(Number.parseInt(raw, 10));
this.setSelectedShaderId(next);
}}
>
${TERRITORY_SHADERS.map(
(s) =>
html`<option value=${String(territoryShaderIntFromId(s.id))}>
${s.label}
</option>`,
)}
</select>
</div>
${shader.options.map((opt) => this.renderOptionControl(opt))}
<div class="sectionTitle">Temporal</div>
<div class="row">
<div class="label">Post Compute</div>
<select
.value=${live(String(territoryPreSmoothingIntFromId(preId)))}
@change=${(e: Event) => {
const raw = (e.target as HTMLSelectElement).value;
const next = territoryPreSmoothingIdFromInt(
Number.parseInt(raw, 10),
);
this.setSelectedPreSmoothingId(next);
}}
>
${TERRITORY_PRE_SMOOTHING.map(
(s) =>
html`<option
value=${String(territoryPreSmoothingIntFromId(s.id))}
>
${s.label}
</option>`,
)}
</select>
</div>
${pre.options.map((opt) => this.renderOptionControl(opt))}
<div class="row">
<div class="label">Post Render</div>
<select
.value=${live(String(territoryPostSmoothingIntFromId(postId)))}
@change=${(e: Event) => {
const raw = (e.target as HTMLSelectElement).value;
const next = territoryPostSmoothingIdFromInt(
Number.parseInt(raw, 10),
);
this.setSelectedPostSmoothingId(next);
}}
>
${TERRITORY_POST_SMOOTHING.map(
(s) =>
html`<option
value=${String(territoryPostSmoothingIntFromId(s.id))}
>
${s.label}
</option>`,
)}
</select>
</div>
${post.options.map((opt) => this.renderOptionControl(opt))}
</div>
`;
}
}