> 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.
diff --git a/apps/stage-web/src/components/Devtools/PerformanceOverlay.vue b/apps/stage-web/src/components/Devtools/PerformanceOverlay.vue
index 0f2518f62..da57d80ab 100644
--- a/apps/stage-web/src/components/Devtools/PerformanceOverlay.vue
+++ b/apps/stage-web/src/components/Devtools/PerformanceOverlay.vue
@@ -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() {
+
diff --git a/apps/stage-web/src/components/Devtools/fps-history.browser.test.ts b/apps/stage-web/src/components/Devtools/fps-history.browser.test.ts
new file mode 100644
index 000000000..3760f1ba7
--- /dev/null
+++ b/apps/stage-web/src/components/Devtools/fps-history.browser.test.ts
@@ -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()
+ })
+})
diff --git a/apps/stage-web/src/components/Devtools/fps-history.vue b/apps/stage-web/src/components/Devtools/fps-history.vue
new file mode 100644
index 000000000..652b0d948
--- /dev/null
+++ b/apps/stage-web/src/components/Devtools/fps-history.vue
@@ -0,0 +1,71 @@
+
+
+
+