feat(stage-web): show FPS history in performance visualizer (#2516)

This commit is contained in:
Neko
2026-09-11 01:46:40 +08:00
committed by GitHub
parent 22b164bab0
commit 69ca0a9171
7 changed files with 171 additions and 2 deletions
+17
View File
@@ -5,3 +5,20 @@
</p> </p>
> Heavily inspired by [Neuro-sama](https://www.youtube.com/@Neurosama) > Heavily inspired by [Neuro-sama](https://www.youtube.com/@Neurosama)
## Performance diagnostics
Run `pnpm dev:web:https` from the repository root. Open `/devtools/performance-visualizer` and enable FPS.
The floating overlay shows the last 10 seconds of FPS history, with time and FPS axes.
The minimum, maximum, and latest values describe the visible samples. Other metrics retain their distribution charts.
Start a recording, then navigate within the app to the scene you want to measure.
Stop the recording within 60 seconds, or let it stop automatically.
Return to the visualizer to inspect the recorded FPS history. Export the recording as CSV for individual timestamps and values.
Each FPS sample is `1000 / frameIntervalMs`, from `requestAnimationFrame` callbacks.
Use these samples to locate changes in animation frame timing. They do not measure GPU presentation or native application FPS.
Keep the page in the foreground. Reloading the page clears the recording.
For device comparisons, use the same scene, model, warm-up period, and recording duration.
The development server and the visible overlay affect performance, so record these conditions with the results.
@@ -8,6 +8,8 @@ import { storeToRefs } from 'pinia'
import { computed, shallowRef, useTemplateRef, watch } from 'vue' import { computed, shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import FpsHistory from './fps-history.vue'
import { useDevtoolsLagStore } from '../../stores/devtools-lag' import { useDevtoolsLagStore } from '../../stores/devtools-lag'
const { n, t } = useI18n() const { n, t } = useI18n()
@@ -56,6 +58,7 @@ const metricsWithStats = computed(() => visibleMetrics.value.map(metric => ({
stats: metricStatsMap.value[metric.key], stats: metricStatsMap.value[metric.key],
}))) })))
const recordingElapsedSeconds = computed(() => Math.min(60, Math.floor(recordingElapsedMs.value / 1000))) const recordingElapsedSeconds = computed(() => Math.min(60, Math.floor(recordingElapsedMs.value / 1000)))
const fpsHistoryEnd = computed(() => buffers.value.fps.at(-1)?.ts ?? 0)
const recordingButtonLabel = computed(() => recording.value const recordingButtonLabel = computed(() => recording.value
? t('tamagotchi.settings.devtools.pages.performance-visualizer.overlay.stop') ? t('tamagotchi.settings.devtools.pages.performance-visualizer.overlay.stop')
: t('tamagotchi.settings.devtools.pages.performance-visualizer.overlay.record')) : t('tamagotchi.settings.devtools.pages.performance-visualizer.overlay.record'))
@@ -212,7 +215,14 @@ function resetPosition() {
</template> </template>
</span> </span>
</div> </div>
<FpsHistory
v-if="metric.key === 'fps'"
:samples="buffers.fps"
:started-at="fpsHistoryEnd - 10000"
:stopped-at="fpsHistoryEnd"
/>
<div <div
v-else
:class="['h-10 overflow-hidden rounded bg-white/5 px-1 py-1', 'flex items-end gap-0.5']" :class="['h-10 overflow-hidden rounded bg-white/5 px-1 py-1', 'flex items-end gap-0.5']"
aria-hidden="true" aria-hidden="true"
> >
@@ -0,0 +1,48 @@
import en from '@proj-airi/i18n/locales/en'
import { describe, expect, it } from 'vitest'
import { render } from 'vitest-browser-vue'
import { createI18n } from 'vue-i18n'
import FpsHistory from './fps-history.vue'
describe('fps history', () => {
it('places a slow frame at its timestamp and keeps the FPS scale above 60 when needed', async () => {
const screen = await render(FpsHistory, {
props: {
samples: [
{ ts: 900, value: 500 },
{ ts: 1000, value: 120 },
{ ts: 1100, value: 60 },
{ ts: 1800, value: 10 },
{ ts: 2100, value: 500 },
],
startedAt: 1000,
stoppedAt: 2000,
},
global: {
plugins: [createI18n({ legacy: false, locale: 'en', messages: { en } })],
},
})
// A histogram loses time order. Equal spacing would also hide the 700 ms gap before the slow frame.
const chart = screen.getByRole('img', { name: 'FPS history' }).element()
expect(chart.querySelector('polyline')?.getAttribute('points')).toBe('0,0 30,30 240,55')
await expect.element(screen.getByText('Latest 10', { exact: true })).toBeVisible()
await expect.element(screen.getByText('Min 10 / Max 120', { exact: true })).toBeVisible()
await expect.element(screen.getByText('-1 s', { exact: true })).toBeVisible()
})
it('shows missing data without inventing a zero FPS sample', async () => {
const screen = await render(FpsHistory, {
props: { samples: [], startedAt: 1000, stoppedAt: 1000 },
global: {
plugins: [createI18n({ legacy: false, locale: 'en', messages: { en } })],
},
})
await expect.element(screen.getByText('No FPS samples. Enable FPS to collect samples.', { exact: true })).toBeVisible()
expect(screen.getByRole('img').element().querySelector('polyline')?.getAttribute('points')).toBe('')
await expect.element(screen.getByText('Latest', { exact: false })).not.toBeInTheDocument()
})
})
@@ -0,0 +1,71 @@
<script setup lang="ts">
import type { useDevtoolsLagStore } from '../../stores/devtools-lag'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
samples: Readonly<ReturnType<typeof useDevtoolsLagStore>['buffers']['fps']>
/** Bounds use the same performance.now() clock as the samples. */
startedAt: number
stoppedAt: number
}>()
const { n, t } = useI18n()
const history = computed(() => {
const samples = props.samples.filter(sample => sample.ts >= props.startedAt
&& sample.ts <= props.stoppedAt && Number.isFinite(sample.value) && sample.value >= 0)
const values = samples.map(sample => sample.value)
const maximum = values.length ? Math.max(...values) : 0
// Keep a zero baseline and 60 FPS steps so a small fluctuation does not fill the chart.
const ceiling = Math.max(60, Math.ceil(maximum / 60) * 60)
const duration = Math.max(1, props.stoppedAt - props.startedAt)
return {
ceiling,
latest: samples.at(-1)?.value,
minimum: values.length ? Math.min(...values) : undefined,
maximum: values.length ? maximum : undefined,
// Timestamp spacing preserves the duration of stalls instead of spacing frames equally.
points: samples.map(sample => `${((sample.ts - props.startedAt) / duration) * 300},${60 - (sample.value / ceiling) * 60}`).join(' '),
}
})
</script>
<template>
<div :class="['flex flex-col gap-1']" data-testid="fps-history">
<div :class="['flex flex-wrap justify-between gap-x-2 text-xs tabular-nums']">
<span>{{ t('tamagotchi.settings.devtools.pages.performance-visualizer.history.title') }}</span>
<span v-if="history.latest !== undefined">
{{ t('tamagotchi.settings.devtools.pages.performance-visualizer.history.latest') }} {{ n(history.latest, { maximumFractionDigits: 1 }) }}
</span>
</div>
<div :class="['flex gap-2']">
<div :class="['flex flex-col justify-between text-[10px] tabular-nums opacity-70']">
<span>{{ n(history.ceiling) }}</span>
<span>{{ n(history.ceiling / 2) }}</span>
<span>0</span>
</div>
<svg
viewBox="0 0 300 60"
preserveAspectRatio="none"
role="img"
:aria-label="t('tamagotchi.settings.devtools.pages.performance-visualizer.history.title')"
:class="['h-16 min-w-0 flex-1 overflow-visible rounded bg-neutral-500/10']"
>
<path d="M0 0H300 M0 30H300 M0 60H300" fill="none" stroke="currentColor" stroke-opacity="0.15" vector-effect="non-scaling-stroke" />
<polyline :points="history.points" fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" />
</svg>
</div>
<div :class="['flex justify-between text-[10px] tabular-nums opacity-70']">
<span>{{ t('tamagotchi.settings.devtools.pages.performance-visualizer.history.seconds-ago', { seconds: n((stoppedAt - startedAt) / 1000, { maximumFractionDigits: 1 }) }) }}</span>
<span>{{ t('tamagotchi.settings.devtools.pages.performance-visualizer.history.end') }}</span>
</div>
<div v-if="history.minimum !== undefined && history.maximum !== undefined" :class="['text-xs tabular-nums opacity-70']">
{{ t('tamagotchi.settings.devtools.pages.performance-visualizer.history.minimum') }} {{ n(history.minimum, { maximumFractionDigits: 1 }) }}
/ {{ t('tamagotchi.settings.devtools.pages.performance-visualizer.history.maximum') }} {{ n(history.maximum, { maximumFractionDigits: 1 }) }}
</div>
<div v-else :class="['text-xs opacity-70']">
{{ t('tamagotchi.settings.devtools.pages.performance-visualizer.history.empty') }}
</div>
</div>
</template>
@@ -7,6 +7,8 @@ import { storeToRefs } from 'pinia'
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import FpsHistory from '../../components/Devtools/fps-history.vue'
import { useDevtoolsLagStore } from '../../stores/devtools-lag' import { useDevtoolsLagStore } from '../../stores/devtools-lag'
const { n, t } = useI18n() const { n, t } = useI18n()
@@ -152,6 +154,11 @@ function metricDescription(metric: typeof metricControls.value[number]) {
<span>{{ t('tamagotchi.settings.devtools.pages.performance-visualizer.metrics.long-task.label') }}: {{ n(lastRecording.samples.longtask.length) }}</span> <span>{{ t('tamagotchi.settings.devtools.pages.performance-visualizer.metrics.long-task.label') }}: {{ n(lastRecording.samples.longtask.length) }}</span>
<span>{{ t('tamagotchi.settings.devtools.pages.performance-visualizer.metrics.memory.label') }}: {{ n(lastRecording.samples.memory.length) }}</span> <span>{{ t('tamagotchi.settings.devtools.pages.performance-visualizer.metrics.memory.label') }}: {{ n(lastRecording.samples.memory.length) }}</span>
</div> </div>
<FpsHistory
:samples="lastRecording.samples.fps"
:started-at="lastRecording.startedAt"
:stopped-at="lastRecording.stoppedAt"
/>
</section> </section>
<div :class="['text-xs text-neutral-500 dark:text-neutral-400']"> <div :class="['text-xs text-neutral-500 dark:text-neutral-400']">
@@ -319,7 +319,7 @@ devtools:
metrics: metrics:
fps: fps:
label: FPS label: FPS
description: Frames per second during the last 10 seconds. description: Animation frame rate during the last 10 seconds. Each sample is 1000 divided by the frame interval in milliseconds.
frame-duration: frame-duration:
label: Frame time (ms) label: Frame time (ms)
description: Time between animation frames during the last 10 seconds. description: Time between animation frames during the last 10 seconds.
@@ -335,6 +335,14 @@ devtools:
title: Last recording title: Last recording
duration: Duration {duration} ms duration: Duration {duration} ms
limit: Recording stops after 60 seconds. limit: Recording stops after 60 seconds.
history:
title: FPS history
latest: Latest
minimum: Min
maximum: Max
seconds-ago: '-{seconds} s'
end: 0 s
empty: No FPS samples. Enable FPS to collect samples.
overlay: overlay:
title: Performance title: Performance
move: Move performance overlay move: Move performance overlay
@@ -68,7 +68,7 @@ devtools:
metrics: metrics:
fps: fps:
label: FPS label: FPS
description: 最近 10 秒内的每秒帧数 description: 最近 10 秒内的动画帧率。每个样本为 1000 除以帧间隔(毫秒)
frame-duration: frame-duration:
label: 帧时间(毫秒) label: 帧时间(毫秒)
description: 最近 10 秒内动画帧之间的时间。 description: 最近 10 秒内动画帧之间的时间。
@@ -84,6 +84,14 @@ devtools:
title: 上次录制 title: 上次录制
duration: 时长 {duration} 毫秒 duration: 时长 {duration} 毫秒
limit: 录制会在 60 秒后停止。 limit: 录制会在 60 秒后停止。
history:
title: FPS 历史
latest: 最新
minimum: 最低
maximum: 最高
seconds-ago: '-{seconds} 秒'
end: 0
empty: 暂无 FPS 样本,请启用 FPS 采集。
overlay: overlay:
title: 性能 title: 性能
move: 移动性能浮窗 move: 移动性能浮窗