import { LitElement, css, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { EventBus } from "../../../core/EventBus"; import { UserSettings } from "../../../core/game/UserSettings"; import { TickMetricsEvent, TogglePerformanceOverlayEvent, } from "../../InputHandler"; import { translateText } from "../../Utils"; import { FrameProfiler } from "../FrameProfiler"; import { Layer } from "./Layer"; @customElement("performance-overlay") export class PerformanceOverlay extends LitElement implements Layer { @property({ type: Object }) public eventBus!: EventBus; @property({ type: Object }) public userSettings!: UserSettings; private subscribedEventBus: EventBus | null = null; private isUserSettingsListenerAttached: boolean = false; @state() private currentFPS: number = 0; @state() private averageFPS: number = 0; @state() private frameTime: number = 0; @state() private tickExecutionAvg: number = 0; @state() private tickExecutionMax: number = 0; @state() private tickDelayAvg: number = 0; @state() private tickDelayMax: number = 0; @state() private isVisible: boolean = false; @state() private currentTPS: number = 0; @state() private averageTPS: number = 0; @state() private isDragging: boolean = false; @state() private position: { x: number; y: number } = { x: 8, y: 8 }; // px values @state() private copyStatus: "idle" | "success" | "error" = "idle"; @state() private renderLayersExpanded: boolean = false; @state() private tickLayersExpanded: boolean = false; @state() private overlayWidthPx: number | null = null; private frameCount: number = 0; private lastTime: number = 0; private frameTimes: number[] = []; private fpsHistory: number[] = []; private lastSecondTime: number = 0; private framesThisSecond: number = 0; private tickExecutionTimes: number[] = []; private tickDelayTimes: number[] = []; private tickTimestamps: number[] = []; private tickHead1s: number = 0; private tickHead60s: number = 0; private copyStatusTimeoutId: ReturnType | null = null; private resizeState: { pointerId: number; startClientX: number; startWidthPx: number; pendingWidthPx: number; } | null = null; private dragState: { pointerId: number; dragStart: { x: number; y: number }; } | null = null; // Smoothed per-layer render timings (EMA over recent frames) private layerStats: Map< string, { avg: number; max: number; last: number; total: number } > = new Map(); @state() private layerBreakdown: { name: string; avg: number; max: number; total: number; }[] = []; // Smoothed per-layer tick timings (EMA over recent ticks) private tickLayerStats: Map< string, { avg: number; max: number; last: number; total: number } > = new Map(); @state() private tickLayerBreakdown: { name: string; avg: number; max: number; total: number; }[] = []; @state() private tickLayerLastCount: number = 0; @state() private tickLayerLastTotalMs: number = 0; @state() private tickLayerLastDurations: Record = {}; @state() private renderLastTickFrameCount: number = 0; @state() private renderLastTickLayerTotalMs: number = 0; @state() private renderLastTickLayerDurations: Record = {}; @state() private layerCounters: Record> = {}; // Smoothed per-layer render-per-tick timings (EMA over recent ticks) private renderPerTickLayerStats: Map< string, { avg: number; max: number; last: number; total: number } > = new Map(); static styles = css` .performance-overlay { position: fixed; top: var(--top, 20px); left: var(--left, 50%); transform: var(--transform, translateX(-50%)); background: rgba(0, 0, 0, 0.8); color: white; padding: 32px 16px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; z-index: 9999; user-select: none; cursor: default; transition: none; box-sizing: border-box; width: var(--overlay-width, min(460px, calc(100vw - 16px))); max-width: calc(100vw - 16px); max-height: calc(100vh - 16px); overflow: hidden; } .overlay-scroll { overflow: auto; max-height: calc(100vh - 56px); } .performance-overlay.dragging { cursor: grabbing; transition: none; opacity: 0.5; } .drag-handle { position: absolute; top: 0; left: 0; right: 12px; /* leave space for the resize handle */ height: 32px; cursor: grab; touch-action: none; pointer-events: auto; } .performance-overlay.dragging .drag-handle { cursor: grabbing; } .performance-line { margin: 2px 0; } .section-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .collapse-button { width: 22px; height: 18px; padding: 0; border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 4px; background: rgba(0, 0, 0, 0.4); color: white; font-family: monospace; font-size: 12px; line-height: 1; cursor: pointer; user-select: none; pointer-events: auto; } .resize-handle { position: absolute; top: 0; right: 0; height: 100%; width: 12px; cursor: ew-resize; touch-action: none; pointer-events: auto; } .resize-handle::after { content: ""; position: absolute; top: 6px; bottom: 6px; right: 4px; width: 2px; border-radius: 2px; background: rgba(255, 255, 255, 0.25); } .performance-good { color: #4ade80; /* green-400 */ } .performance-warning { color: #fbbf24; /* amber-400 */ } .performance-bad { color: #f87171; /* red-400 */ } .close-button { position: absolute; top: 8px; right: 8px; width: 20px; height: 20px; background-color: rgba(0, 0, 0, 0.8); border-radius: 4px; color: white; font-size: 14px; font-weight: bold; cursor: pointer; display: flex; align-items: center; justify-content: center; line-height: 1; user-select: none; pointer-events: auto; } .reset-button { position: absolute; top: 8px; left: 8px; height: 20px; padding: 0 6px; background-color: rgba(0, 0, 0, 0.8); border-radius: 4px; color: white; font-size: 10px; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; line-height: 1; user-select: none; pointer-events: auto; } .copy-json-button { position: absolute; top: 8px; left: 70px; height: 20px; padding: 0 6px; background-color: rgba(0, 0, 0, 0.8); border-radius: 4px; color: white; font-size: 10px; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; line-height: 1; user-select: none; pointer-events: auto; } .layers-section { margin-top: 4px; border-top: 1px solid rgba(255, 255, 255, 0.1); padding-top: 4px; } .layer-row { display: flex; align-items: center; gap: 6px; font-size: 11px; margin-top: 2px; padding: 2px 4px; border-radius: 3px; background: linear-gradient( 90deg, rgba(56, 189, 248, 0.35) 0%, rgba(56, 189, 248, 0.35) var(--pct, 0%), rgba(56, 189, 248, 0) var(--pct, 0%), rgba(56, 189, 248, 0) 100% ); } .layer-row.table-header { background: none; opacity: 0.75; font-size: 11px; margin-top: 4px; } .layer-row.inactive { opacity: 0.5; } .layer-name { flex: 0 0 280px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .layer-metrics { flex: 0 0 auto; white-space: nowrap; } `; constructor() { super(); } private onTogglePerformanceOverlay = ( _event: TogglePerformanceOverlayEvent, ) => { const nextVisible = !this.isVisible; this.setVisible(nextVisible); this.userSettings.set("settings.performanceOverlay", nextVisible); }; private onTickMetricsEvent = (event: TickMetricsEvent) => { this.updateTickMetrics(event.tickExecutionDuration, event.tickDelay); }; private onUserSettingsChanged = (event: Event) => { const customEvent = event as CustomEvent<{ key?: string; value?: unknown; }>; if (customEvent.detail?.key !== "settings.performanceOverlay") return; const nextVisible = customEvent.detail.value === true; if (this.isVisible === nextVisible) return; this.setVisible(nextVisible); }; init() { this.setVisible(this.userSettings.performanceOverlay()); if (this.subscribedEventBus && this.subscribedEventBus !== this.eventBus) { this.subscribedEventBus.off( TogglePerformanceOverlayEvent, this.onTogglePerformanceOverlay, ); this.subscribedEventBus.off(TickMetricsEvent, this.onTickMetricsEvent); this.subscribedEventBus = null; } if (this.subscribedEventBus !== this.eventBus) { this.eventBus.on( TogglePerformanceOverlayEvent, this.onTogglePerformanceOverlay, ); this.eventBus.on(TickMetricsEvent, this.onTickMetricsEvent); this.subscribedEventBus = this.eventBus; } if (!this.isUserSettingsListenerAttached) { globalThis.addEventListener( "user-settings-changed", this.onUserSettingsChanged, ); this.isUserSettingsListenerAttached = true; } } disconnectedCallback(): void { super.disconnectedCallback(); if (this.isUserSettingsListenerAttached) { globalThis.removeEventListener( "user-settings-changed", this.onUserSettingsChanged, ); this.isUserSettingsListenerAttached = false; } if (this.subscribedEventBus) { this.subscribedEventBus.off( TogglePerformanceOverlayEvent, this.onTogglePerformanceOverlay, ); this.subscribedEventBus.off(TickMetricsEvent, this.onTickMetricsEvent); this.subscribedEventBus = null; } if (this.copyStatusTimeoutId) { clearTimeout(this.copyStatusTimeoutId); this.copyStatusTimeoutId = null; } if (this.resizeState) { globalThis.removeEventListener("pointermove", this.onResizePointerMove); globalThis.removeEventListener("pointerup", this.onResizePointerUp); globalThis.removeEventListener("pointercancel", this.onResizePointerUp); this.resizeState = null; } if (this.dragState) { globalThis.removeEventListener("pointermove", this.onDragPointerMove); globalThis.removeEventListener("pointerup", this.onDragPointerUp); globalThis.removeEventListener("pointercancel", this.onDragPointerUp); this.dragState = null; this.isDragging = false; } } setVisible(visible: boolean) { this.isVisible = visible; FrameProfiler.setEnabled(visible); if (!visible && this.resizeState) { globalThis.removeEventListener("pointermove", this.onResizePointerMove); globalThis.removeEventListener("pointerup", this.onResizePointerUp); globalThis.removeEventListener("pointercancel", this.onResizePointerUp); this.resizeState = null; } if (!visible && this.dragState) { globalThis.removeEventListener("pointermove", this.onDragPointerMove); globalThis.removeEventListener("pointerup", this.onDragPointerUp); globalThis.removeEventListener("pointercancel", this.onDragPointerUp); this.dragState = null; this.isDragging = false; } this.requestUpdate(); } private handleClose() { const nextVisible = false; this.setVisible(nextVisible); this.userSettings.set("settings.performanceOverlay", nextVisible); } private onDragPointerMove = (e: PointerEvent) => { if (!this.dragState || e.pointerId !== this.dragState.pointerId) return; const newX = e.clientX - this.dragState.dragStart.x; const newY = e.clientY - this.dragState.dragStart.y; const margin = 8; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const defaultWidth = Math.min(460, Math.max(0, viewportWidth - margin * 2)); const overlayWidth = Math.min( this.overlayWidthPx ?? defaultWidth, viewportWidth - margin * 2, ); this.position = { x: Math.max( margin, Math.min(viewportWidth - overlayWidth - margin, newX), ), y: Math.max(margin, Math.min(viewportHeight - 100, newY)), }; this.requestUpdate(); }; private onDragPointerUp = (e: PointerEvent) => { if (!this.dragState || e.pointerId !== this.dragState.pointerId) return; globalThis.removeEventListener("pointermove", this.onDragPointerMove); globalThis.removeEventListener("pointerup", this.onDragPointerUp); globalThis.removeEventListener("pointercancel", this.onDragPointerUp); this.dragState = null; this.isDragging = false; this.requestUpdate(); }; private handleDragPointerDown = (e: PointerEvent) => { e.preventDefault(); e.stopPropagation(); this.isDragging = true; this.dragState = { pointerId: e.pointerId, dragStart: { x: e.clientX - this.position.x, y: e.clientY - this.position.y, }, }; globalThis.addEventListener("pointermove", this.onDragPointerMove); globalThis.addEventListener("pointerup", this.onDragPointerUp); globalThis.addEventListener("pointercancel", this.onDragPointerUp); }; private onResizePointerMove = (e: PointerEvent) => { if (!this.resizeState || e.pointerId !== this.resizeState.pointerId) return; const margin = 8; const viewportWidth = window.innerWidth; const left = Math.max(margin, Math.min(this.position.x, viewportWidth)); const maxWidthPx = Math.max(120, viewportWidth - left - margin); const minWidthPx = Math.min(260, maxWidthPx); const delta = e.clientX - this.resizeState.startClientX; const nextWidth = this.resizeState.startWidthPx + delta; const clamped = Math.max(minWidthPx, Math.min(maxWidthPx, nextWidth)); this.resizeState.pendingWidthPx = clamped; const overlay = this.renderRoot.querySelector( ".performance-overlay", ); overlay?.style.setProperty("--overlay-width", `${clamped}px`); }; private onResizePointerUp = (e: PointerEvent) => { if (!this.resizeState || e.pointerId !== this.resizeState.pointerId) return; globalThis.removeEventListener("pointermove", this.onResizePointerMove); globalThis.removeEventListener("pointerup", this.onResizePointerUp); globalThis.removeEventListener("pointercancel", this.onResizePointerUp); this.overlayWidthPx = this.resizeState.pendingWidthPx; this.resizeState = null; this.requestUpdate(); }; private handleResizePointerDown = (e: PointerEvent) => { e.preventDefault(); e.stopPropagation(); const overlay = this.renderRoot.querySelector( ".performance-overlay", ); const startWidth = overlay?.getBoundingClientRect().width ?? 460; this.resizeState = { pointerId: e.pointerId, startClientX: e.clientX, startWidthPx: startWidth, pendingWidthPx: startWidth, }; globalThis.addEventListener("pointermove", this.onResizePointerMove); globalThis.addEventListener("pointerup", this.onResizePointerUp); globalThis.addEventListener("pointercancel", this.onResizePointerUp); }; private handleReset = () => { // reset FPS / frame stats this.frameCount = 0; this.lastTime = 0; this.frameTimes = []; this.fpsHistory = []; this.lastSecondTime = 0; this.framesThisSecond = 0; this.currentFPS = 0; this.averageFPS = 0; this.frameTime = 0; // reset tick metrics this.tickExecutionTimes = []; this.tickDelayTimes = []; this.tickExecutionAvg = 0; this.tickExecutionMax = 0; this.tickDelayAvg = 0; this.tickDelayMax = 0; this.currentTPS = 0; this.averageTPS = 0; this.tickTimestamps = []; this.tickHead1s = 0; this.tickHead60s = 0; // reset layer breakdown this.layerStats.clear(); this.layerBreakdown = []; // reset tick layer breakdown this.tickLayerStats.clear(); this.tickLayerBreakdown = []; this.tickLayerLastCount = 0; this.tickLayerLastTotalMs = 0; this.tickLayerLastDurations = {}; this.renderLastTickFrameCount = 0; this.renderLastTickLayerTotalMs = 0; this.renderLastTickLayerDurations = {}; this.layerCounters = {}; this.renderPerTickLayerStats.clear(); this.renderLayersExpanded = false; this.tickLayersExpanded = false; this.requestUpdate(); }; private toggleRenderLayersExpanded = (e: Event) => { e.preventDefault(); e.stopPropagation(); this.renderLayersExpanded = !this.renderLayersExpanded; }; private toggleTickLayersExpanded = (e: Event) => { e.preventDefault(); e.stopPropagation(); this.tickLayersExpanded = !this.tickLayersExpanded; }; updateFrameMetrics( frameDuration: number, layerDurations?: Record, ) { if (!this.isVisible) return; const now = performance.now(); // Initialize timing on first call if (this.lastTime === 0) { this.lastTime = now; this.lastSecondTime = now; return; } const deltaTime = now - this.lastTime; // Track frame times for current FPS calculation (last 60 frames) this.frameTimes.push(deltaTime); if (this.frameTimes.length > 60) { this.frameTimes.shift(); } // Calculate current FPS based on average frame time if (this.frameTimes.length > 0) { const avgFrameTime = this.frameTimes.reduce((a, b) => a + b, 0) / this.frameTimes.length; this.currentFPS = Math.round(1000 / avgFrameTime); this.frameTime = Math.round(avgFrameTime); } // Track FPS for 60-second average this.framesThisSecond++; // Update every second if (now - this.lastSecondTime >= 1000) { this.fpsHistory.push(this.framesThisSecond); if (this.fpsHistory.length > 60) { this.fpsHistory.shift(); } // Calculate 60-second average if (this.fpsHistory.length > 0) { this.averageFPS = Math.round( this.fpsHistory.reduce((a, b) => a + b, 0) / this.fpsHistory.length, ); } this.framesThisSecond = 0; this.lastSecondTime = now; } this.lastTime = now; this.frameCount++; if (layerDurations) { this.updateLayerStats(layerDurations); } this.requestUpdate(); } private updateLayerStats(layerDurations: Record) { const alpha = 0.2; // smoothing factor for EMA Object.entries(layerDurations).forEach(([name, duration]) => { const existing = this.layerStats.get(name); if (!existing) { this.layerStats.set(name, { avg: duration, max: duration, last: duration, total: duration, }); } else { const avg = existing.avg + alpha * (duration - existing.avg); const max = Math.max(existing.max, duration); const total = existing.total + duration; this.layerStats.set(name, { avg, max, last: duration, total }); } }); // Derive contributors sorted by total accumulated time spent const breakdown = Array.from(this.layerStats.entries()) .map(([name, stats]) => ({ name, avg: stats.avg, max: stats.max, total: stats.total, })) .sort((a, b) => b.total - a.total); this.layerBreakdown = breakdown; } updateRenderPerTickMetrics( frameCount: number, layerDurations: Record, ) { if (!this.isVisible) return; const alpha = 0.2; // smoothing factor for EMA this.renderLastTickFrameCount = frameCount; this.renderLastTickLayerDurations = { ...layerDurations }; this.renderLastTickLayerTotalMs = Object.values(layerDurations).reduce( (acc, ms) => acc + ms, 0, ); for (const [name, duration] of Object.entries(layerDurations)) { const existing = this.renderPerTickLayerStats.get(name); if (!existing) { this.renderPerTickLayerStats.set(name, { avg: duration, max: duration, last: duration, total: duration, }); continue; } const avg = existing.avg + alpha * (duration - existing.avg); const max = Math.max(existing.max, duration); const total = existing.total + duration; this.renderPerTickLayerStats.set(name, { avg, max, last: duration, total, }); } } updateTickLayerMetrics(tickLayerDurations: Record) { if (!this.isVisible) return; const alpha = 0.2; // smoothing factor for EMA const entries = Object.entries(tickLayerDurations); this.tickLayerLastCount = entries.length; this.tickLayerLastDurations = { ...tickLayerDurations }; this.tickLayerLastTotalMs = entries.reduce((acc, [, duration]) => { return acc + duration; }, 0); entries.forEach(([name, duration]) => { const existing = this.tickLayerStats.get(name); if (!existing) { this.tickLayerStats.set(name, { avg: duration, max: duration, last: duration, total: duration, }); } else { const avg = existing.avg + alpha * (duration - existing.avg); const max = Math.max(existing.max, duration); const total = existing.total + duration; this.tickLayerStats.set(name, { avg, max, last: duration, total }); } }); const breakdown = Array.from(this.tickLayerStats.entries()) .map(([name, stats]) => ({ name, avg: stats.avg, max: stats.max, total: stats.total, })) .sort((a, b) => b.total - a.total); this.tickLayerBreakdown = breakdown; } updateLayerCounters(counters: Record>) { if (!this.isVisible) return; this.layerCounters = counters; } updateTickMetrics(tickExecutionDuration?: number, tickDelay?: number) { if (!this.isVisible) return; const now = performance.now(); this.tickTimestamps.push(now); while ( this.tickHead1s < this.tickTimestamps.length && now - this.tickTimestamps[this.tickHead1s] > 1000 ) { this.tickHead1s++; } while ( this.tickHead60s < this.tickTimestamps.length && now - this.tickTimestamps[this.tickHead60s] > 60000 ) { this.tickHead60s++; } const ticksLast1s = this.tickTimestamps.length - this.tickHead1s; const ticksLast60s = this.tickTimestamps.length - this.tickHead60s; this.currentTPS = ticksLast1s; const oldest60 = ticksLast60s > 0 ? this.tickTimestamps[this.tickHead60s] : now; const elapsed60s = Math.min(60, Math.max(1, (now - oldest60) / 1000)); this.averageTPS = Math.round((ticksLast60s / elapsed60s) * 10) / 10; // Compact occasionally to avoid unbounded growth on long sessions. if (this.tickHead60s > 4000) { this.tickTimestamps = this.tickTimestamps.slice(this.tickHead60s); this.tickHead1s = Math.max(0, this.tickHead1s - this.tickHead60s); this.tickHead60s = 0; } // Update tick execution duration stats if (tickExecutionDuration !== undefined) { this.tickExecutionTimes.push(tickExecutionDuration); if (this.tickExecutionTimes.length > 60) { this.tickExecutionTimes.shift(); } if (this.tickExecutionTimes.length > 0) { const avg = this.tickExecutionTimes.reduce((a, b) => a + b, 0) / this.tickExecutionTimes.length; this.tickExecutionAvg = Math.round(avg * 100) / 100; this.tickExecutionMax = Math.round( Math.max(...this.tickExecutionTimes), ); } } // Update tick delay stats if (tickDelay !== undefined) { this.tickDelayTimes.push(tickDelay); if (this.tickDelayTimes.length > 60) { this.tickDelayTimes.shift(); } if (this.tickDelayTimes.length > 0) { const avg = this.tickDelayTimes.reduce((a, b) => a + b, 0) / this.tickDelayTimes.length; this.tickDelayAvg = Math.round(avg * 100) / 100; this.tickDelayMax = Math.round(Math.max(...this.tickDelayTimes)); } } this.requestUpdate(); } shouldTransform(): boolean { return false; } private getPerformanceColor(fps: number): string { if (fps >= 55) return "performance-good"; if (fps >= 30) return "performance-warning"; return "performance-bad"; } private getTPSColor(tps: number): string { if (tps >= 18) return "performance-good"; if (tps >= 10) return "performance-warning"; return "performance-bad"; } private buildPerformanceSnapshot() { return { timestamp: new Date().toISOString(), fps: { current: this.currentFPS, average60s: this.averageFPS, frameTimeMs: this.frameTime, history: [...this.fpsHistory], }, tps: { current: this.currentTPS, average60s: this.averageTPS, }, ticks: { executionAvgMs: this.tickExecutionAvg, executionMaxMs: this.tickExecutionMax, delayAvgMs: this.tickDelayAvg, delayMaxMs: this.tickDelayMax, executionSamples: [...this.tickExecutionTimes], delaySamples: [...this.tickDelayTimes], }, renderPerTickLast: { frames: this.renderLastTickFrameCount, layerTotalMs: this.renderLastTickLayerTotalMs, layers: { ...this.renderLastTickLayerDurations }, }, layers: this.layerBreakdown.map((layer) => ({ ...layer })), tickLayers: this.tickLayerBreakdown.map((layer) => ({ ...layer })), layerCounters: { ...this.layerCounters }, }; } private clearCopyStatusTimeout() { if (this.copyStatusTimeoutId !== null) { clearTimeout(this.copyStatusTimeoutId); this.copyStatusTimeoutId = null; } } private scheduleCopyStatusReset() { this.clearCopyStatusTimeout(); this.copyStatusTimeoutId = setTimeout(() => { this.copyStatus = "idle"; this.copyStatusTimeoutId = null; this.requestUpdate(); }, 2000); } private async handleCopyJson() { const snapshot = this.buildPerformanceSnapshot(); const json = JSON.stringify(snapshot, null, 2); try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(json); } else { const textarea = document.createElement("textarea"); textarea.value = json; textarea.style.position = "fixed"; textarea.style.left = "-9999px"; document.body.appendChild(textarea); textarea.select(); document.execCommand("copy"); document.body.removeChild(textarea); } this.copyStatus = "success"; } catch (err) { console.warn("Failed to copy performance snapshot", err); this.copyStatus = "error"; } this.scheduleCopyStatusReset(); } render() { if (!this.isVisible) { return html``; } const margin = 8; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const defaultWidth = Math.min(460, Math.max(0, viewportWidth - margin * 2)); const overlayWidth = Math.min( this.overlayWidthPx ?? defaultWidth, viewportWidth - margin * 2, ); const maxLeft = Math.max(margin, viewportWidth - overlayWidth - margin); const clampedX = Math.max(margin, Math.min(this.position.x, maxLeft)); const clampedY = Math.max( margin, Math.min(this.position.y, viewportHeight - 100), ); const copyLabel = this.copyStatus === "success" ? translateText("performance_overlay.copied") : this.copyStatus === "error" ? translateText("performance_overlay.failed_copy") : translateText("performance_overlay.copy_clipboard"); const renderLayersToShow = this.layerBreakdown.slice(0, 10); const tickLayersToShow = this.tickLayerBreakdown.slice(0, 10); const unitLayerCounters = this.layerCounters.UnitLayer ?? null; const maxLayerAvg = renderLayersToShow.length > 0 ? Math.max(...renderLayersToShow.map((l) => l.avg)) : 1; const maxTickLayerAvg = tickLayersToShow.length > 0 ? Math.max(...tickLayersToShow.map((l) => l.avg)) : 1; const overlayWidthStyle = this.overlayWidthPx === null ? "" : `--overlay-width: ${this.overlayWidthPx}px;`; return html`
${translateText("performance_overlay.fps")} ${this.currentFPS}
${translateText("performance_overlay.avg_60s")} ${this.averageFPS}
${translateText("performance_overlay.frame")} ${this.frameTime}ms
${translateText("performance_overlay.tps")} ${this.currentTPS} (${translateText("performance_overlay.tps_avg_60s")} ${this.averageTPS})
${translateText("performance_overlay.tick_exec")} ${this.tickExecutionAvg.toFixed(2)}ms (max: ${this.tickExecutionMax}ms)
${translateText("performance_overlay.tick_delay")} ${this.tickDelayAvg.toFixed(2)}ms (max: ${this.tickDelayMax}ms)
${this.layerBreakdown.length ? html`
${translateText("performance_overlay.layers_header")}
${translateText("performance_overlay.render_layers_summary", { frames: this.renderLastTickFrameCount, ms: this.renderLastTickLayerTotalMs.toFixed(2), })}
${this.renderLayersExpanded ? html`
${translateText( "performance_overlay.render_layers_table_header", )}
${renderLayersToShow.map((layer) => { const width = Math.min( 100, (layer.avg / maxLayerAvg) * 100 || 0, ); const perTickRenderMs = this.renderLastTickLayerDurations[layer.name] ?? 0; const perTickRenderAvgMs = this.renderPerTickLayerStats.get(layer.name)?.avg ?? 0; const isInactive = perTickRenderMs <= 0.01; const title = `${layer.name} | last tick render: ${perTickRenderMs.toFixed( 2, )}ms`; return html`
${layer.name} ${layer.avg.toFixed(2)} / ${layer.max.toFixed(2)}ms | ${perTickRenderAvgMs.toFixed(2)}ms
`; })}` : html``}
` : html``} ${this.tickLayerBreakdown.length ? html`
${translateText( "performance_overlay.tick_layers_header", )}
${translateText("performance_overlay.tick_layers_summary", { count: this.tickLayerLastCount, ms: this.tickLayerLastTotalMs.toFixed(2), })}
${this.tickLayersExpanded ? html`
${translateText( "performance_overlay.tick_layers_table_header", )}
${tickLayersToShow.map((layer) => { const width = Math.min( 100, (layer.avg / maxTickLayerAvg) * 100 || 0, ); const lastTickMs = this.tickLayerLastDurations[layer.name] ?? 0; const isInactive = lastTickMs <= 0.01; const title = `${layer.name} | last tick: ${lastTickMs.toFixed(2)}ms`; return html`
${layer.name} ${layer.avg.toFixed(2)} / ${layer.max.toFixed(2)}ms
`; })}` : html``}
` : html``} ${unitLayerCounters ? html`
UnitLayer Counters
tracked: ${Number(unitLayerCounters.moversTrackedTotal ?? 0)} sampled: ${Number(unitLayerCounters.moversSampled ?? 0)} drawn: ${Number(unitLayerCounters.moversDrawn ?? 0)} skipped: ${Number(unitLayerCounters.moversSkipped ?? 0)}
moverCanvasScale: ${Number(unitLayerCounters.moverCanvasScale ?? 0).toFixed(0)} rescale(last/avg/count): ${Number(unitLayerCounters.moverCanvasRescaleLastMs ?? 0).toFixed(2)}ms / ${Number(unitLayerCounters.moverCanvasRescaleAvgMs ?? 0).toFixed(2)}ms / ${Number(unitLayerCounters.moverCanvasRescaleCount ?? 0).toFixed(0)}
draw: ${Number(unitLayerCounters.drawTimeMs ?? 0).toFixed(2)}ms / ${Number(unitLayerCounters.budgetTargetMs ?? 0).toFixed(1)}ms (+${Number( unitLayerCounters.budgetSoftOverrunMs ?? 0, ).toFixed(1)}ms on-screen) avgOnDebt: ${Number(unitLayerCounters.avgOnScreenDebt ?? 0).toFixed(2)} maxOnDebt: ${Number(unitLayerCounters.maxOnScreenDebt ?? 0).toFixed(0)}
` : html``}
`; } }