206 lines
4.8 KiB
Vue
206 lines
4.8 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
|
import { RotateCcw } from "@lucide/vue";
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
defineOptions({ inheritAttrs: false });
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
sampleInterval?: number;
|
|
historySize?: number;
|
|
baseFps?: number;
|
|
}>(),
|
|
{
|
|
sampleInterval: 500,
|
|
historySize: 48,
|
|
baseFps: 60,
|
|
},
|
|
);
|
|
|
|
const currentFps = ref(0);
|
|
const averageFps = ref(0);
|
|
const minimumFps = ref<number | null>(null);
|
|
const history = ref<number[]>([]);
|
|
const normalizedSampleInterval = computed(() => Math.max(100, props.sampleInterval));
|
|
const normalizedHistorySize = computed(() => Math.max(1, Math.floor(props.historySize)));
|
|
const normalizedBaseFps = computed(() => Math.max(1, props.baseFps));
|
|
|
|
let animationFrameId = 0;
|
|
let sampleFrames = 0;
|
|
let sampleStartedAt = 0;
|
|
let totalFrames = 0;
|
|
let totalTime = 0;
|
|
|
|
function resetSampleWindow() {
|
|
sampleFrames = 0;
|
|
sampleStartedAt = performance.now();
|
|
}
|
|
|
|
function tick(now: number) {
|
|
sampleFrames += 1;
|
|
totalFrames += 1;
|
|
|
|
const elapsed = now - sampleStartedAt;
|
|
if (elapsed >= normalizedSampleInterval.value) {
|
|
const fps = Math.round((sampleFrames * 1000) / elapsed);
|
|
currentFps.value = fps;
|
|
minimumFps.value = minimumFps.value == null ? fps : Math.min(minimumFps.value, fps);
|
|
totalTime += elapsed;
|
|
averageFps.value = Math.round((totalFrames * 1000) / totalTime);
|
|
history.value = [...history.value, fps].slice(-normalizedHistorySize.value);
|
|
sampleFrames = 0;
|
|
sampleStartedAt = now;
|
|
}
|
|
|
|
animationFrameId = requestAnimationFrame(tick);
|
|
}
|
|
|
|
function reset() {
|
|
currentFps.value = 0;
|
|
averageFps.value = 0;
|
|
minimumFps.value = null;
|
|
history.value = [];
|
|
totalFrames = 0;
|
|
totalTime = 0;
|
|
resetSampleWindow();
|
|
}
|
|
|
|
function handleVisibilityChange() {
|
|
if (!document.hidden) resetSampleWindow();
|
|
}
|
|
|
|
onMounted(() => {
|
|
resetSampleWindow();
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
animationFrameId = requestAnimationFrame(tick);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
cancelAnimationFrame(animationFrameId);
|
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
});
|
|
|
|
function getFpsColor(fps: number) {
|
|
if (fps >= normalizedBaseFps.value * (5 / 6)) return "var(--chart-2)";
|
|
if (fps >= normalizedBaseFps.value / 2) return "var(--chart-4)";
|
|
return "var(--destructive)";
|
|
}
|
|
|
|
const currentFpsColor = computed(() => getFpsColor(currentFps.value));
|
|
|
|
function getBarStyle(fps: number) {
|
|
const height = Math.max(8, Math.min(100, (fps / normalizedBaseFps.value) * 100));
|
|
return {
|
|
height: `${height}%`,
|
|
backgroundColor: getFpsColor(fps),
|
|
};
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<aside class="fps-monitor" aria-label="FPS 性能监控" v-bind="$attrs">
|
|
<div class="fps-monitor__header">
|
|
<span class="fps-monitor__label">FPS</span>
|
|
<strong class="fps-monitor__current" :style="{ color: currentFpsColor }">
|
|
{{ currentFps || "--" }}
|
|
</strong>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
type="button"
|
|
title="重置统计"
|
|
aria-label="重置 FPS 统计"
|
|
@click="reset"
|
|
>
|
|
<RotateCcw />
|
|
</Button>
|
|
</div>
|
|
|
|
<div class="fps-monitor__chart" aria-hidden="true">
|
|
<span
|
|
v-for="(fps, index) in history"
|
|
:key="index"
|
|
class="fps-monitor__bar"
|
|
:style="getBarStyle(fps)"
|
|
/>
|
|
</div>
|
|
|
|
<div class="fps-monitor__summary">
|
|
<span
|
|
>avg <b>{{ averageFps || "--" }}</b></span
|
|
>
|
|
<span
|
|
>min <b>{{ minimumFps ?? "--" }}</b></span
|
|
>
|
|
</div>
|
|
</aside>
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
.fps-monitor {
|
|
position: fixed;
|
|
top: 8px;
|
|
right: 8px;
|
|
z-index: 1000;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
width: 132px;
|
|
padding: 8px 10px;
|
|
color: var(--foreground);
|
|
font-size: 12px;
|
|
line-height: 1.4;
|
|
letter-spacing: 0;
|
|
user-select: none;
|
|
background: var(--background);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-md);
|
|
box-shadow: 0 2px 8px color-mix(in srgb, var(--foreground) 10%, transparent);
|
|
}
|
|
|
|
.fps-monitor__header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
min-height: 24px;
|
|
}
|
|
|
|
.fps-monitor__label {
|
|
color: var(--muted-foreground);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.fps-monitor__current {
|
|
flex: 1;
|
|
font-size: 18px;
|
|
font-variant-numeric: tabular-nums;
|
|
line-height: 1;
|
|
}
|
|
|
|
.fps-monitor__chart {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
gap: 1px;
|
|
height: 32px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.fps-monitor__bar {
|
|
flex: 1;
|
|
min-width: 0;
|
|
border-radius: 1px;
|
|
}
|
|
|
|
.fps-monitor__summary {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
color: var(--muted-foreground);
|
|
}
|
|
|
|
.fps-monitor__summary b {
|
|
color: var(--foreground);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
</style>
|