feat(stage-web): show FPS history in performance visualizer (#2516)
This commit is contained in:
@@ -5,3 +5,20 @@
|
||||
</p>
|
||||
|
||||
> 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 { useI18n } from 'vue-i18n'
|
||||
|
||||
import FpsHistory from './fps-history.vue'
|
||||
|
||||
import { useDevtoolsLagStore } from '../../stores/devtools-lag'
|
||||
|
||||
const { n, t } = useI18n()
|
||||
@@ -56,6 +58,7 @@ const metricsWithStats = computed(() => visibleMetrics.value.map(metric => ({
|
||||
stats: metricStatsMap.value[metric.key],
|
||||
})))
|
||||
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
|
||||
? t('tamagotchi.settings.devtools.pages.performance-visualizer.overlay.stop')
|
||||
: t('tamagotchi.settings.devtools.pages.performance-visualizer.overlay.record'))
|
||||
@@ -212,7 +215,14 @@ function resetPosition() {
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<FpsHistory
|
||||
v-if="metric.key === 'fps'"
|
||||
:samples="buffers.fps"
|
||||
:started-at="fpsHistoryEnd - 10000"
|
||||
:stopped-at="fpsHistoryEnd"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
:class="['h-10 overflow-hidden rounded bg-white/5 px-1 py-1', 'flex items-end gap-0.5']"
|
||||
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 { useI18n } from 'vue-i18n'
|
||||
|
||||
import FpsHistory from '../../components/Devtools/fps-history.vue'
|
||||
|
||||
import { useDevtoolsLagStore } from '../../stores/devtools-lag'
|
||||
|
||||
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.memory.label') }}: {{ n(lastRecording.samples.memory.length) }}</span>
|
||||
</div>
|
||||
<FpsHistory
|
||||
:samples="lastRecording.samples.fps"
|
||||
:started-at="lastRecording.startedAt"
|
||||
:stopped-at="lastRecording.stoppedAt"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div :class="['text-xs text-neutral-500 dark:text-neutral-400']">
|
||||
|
||||
@@ -319,7 +319,7 @@ devtools:
|
||||
metrics:
|
||||
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:
|
||||
label: Frame time (ms)
|
||||
description: Time between animation frames during the last 10 seconds.
|
||||
@@ -335,6 +335,14 @@ devtools:
|
||||
title: Last recording
|
||||
duration: Duration {duration} ms
|
||||
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:
|
||||
title: Performance
|
||||
move: Move performance overlay
|
||||
|
||||
@@ -68,7 +68,7 @@ devtools:
|
||||
metrics:
|
||||
fps:
|
||||
label: FPS
|
||||
description: 最近 10 秒内的每秒帧数。
|
||||
description: 最近 10 秒内的动画帧率。每个样本为 1000 除以帧间隔(毫秒)。
|
||||
frame-duration:
|
||||
label: 帧时间(毫秒)
|
||||
description: 最近 10 秒内动画帧之间的时间。
|
||||
@@ -84,6 +84,14 @@ devtools:
|
||||
title: 上次录制
|
||||
duration: 时长 {duration} 毫秒
|
||||
limit: 录制会在 60 秒后停止。
|
||||
history:
|
||||
title: FPS 历史
|
||||
latest: 最新
|
||||
minimum: 最低
|
||||
maximum: 最高
|
||||
seconds-ago: '-{seconds} 秒'
|
||||
end: 0 秒
|
||||
empty: 暂无 FPS 样本,请启用 FPS 采集。
|
||||
overlay:
|
||||
title: 性能
|
||||
move: 移动性能浮窗
|
||||
|
||||
Reference in New Issue
Block a user