Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions input_viewer_electron/src/main/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,30 @@ function gpuReportPath() {
return path.join(app.getPath('userData'), 'gpu-report.txt')
}

// Frame-rate report (fps-report.txt).
//
// Same discipline as the GPU report and for the same reason: **one file,
// overwritten, never appended.** The renderer holds one row per saver in memory and
// sends a formatted body, so the size is bounded by the number of savers -- about
// 32 rows -- rather than by how long the wall has been running. A 60s cadence on a
// few KB of overwrite is nothing; the same cadence on an append would be the
// gigabytes this was asked to avoid.
const FPS_REPORT_MAX_LINES = 60

ipcMain.handle('write-fps-report', (event, body) => {
const file = path.join(app.getPath('userData'), 'fps-report.txt')
try {
const header = `Input Viewer ${app.getVersion()} -- frame rate report\n` +
`written ${new Date().toISOString()}\n`
const lines = String(body ?? '').split('\n').slice(0, FPS_REPORT_MAX_LINES)
fs.writeFileSync(file, header + lines.join('\n') + '\n')
return file
} catch (err) {
console.error('[FPS] report write failed:', err)
return null
}
})

ipcMain.handle('write-gpu-report', async (event, rendererInfo) => {
const file = gpuReportPath()
const lines = []
Expand Down
4 changes: 4 additions & 0 deletions input_viewer_electron/src/preload/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
// writes a single overwritten file.
writeGpuReport: (rendererInfo) => ipcRenderer.invoke('write-gpu-report', rendererInfo),

// Frame-rate report. One overwritten file; the renderer sends a formatted body
// rather than raw samples, so main stays a dumb writer.
writeFpsReport: (body) => ipcRenderer.invoke('write-fps-report', body),

// System volume control
getSystemVolume: () => ipcRenderer.invoke('get-system-volume'),
setSystemVolume: (volume) => ipcRenderer.invoke('set-system-volume', volume),
Expand Down
129 changes: 128 additions & 1 deletion input_viewer_electron/src/renderer/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {
} from './screensavers/registry.js'
import { installWeatherSource } from './screensavers/weather-source.js'
import { installArtnetSync, getArtnetSync } from './screensavers/artnet-sync.js'
import { observeFrames } from './screensavers/gl-base.js'
import { observeFrames, sampleFrameCounters, setNextRuntimeLabel } from './screensavers/gl-base.js'

// Imported directly rather than through the registry: the split-flap board is
// the no-signal display, not one of the rotating screensavers (#92).
Expand Down Expand Up @@ -1000,6 +1000,9 @@ function startNoSignalBoard(side, overlay) {
canvas.width = Math.max(1, Math.round(rect.width))
canvas.height = Math.max(1, Math.round(rect.height))
try {
// Named per side: in dual view there are two boards, each its own runtime, and
// an aggregate would hide one being slower than the other.
setNextRuntimeLabel(`Split Flap (${side})`)
const board = splitFlap.create(canvas)
board.start()
noSignalBoards[side] = board
Expand Down Expand Up @@ -1536,6 +1539,124 @@ function updateDropdownVisibility() {
/**
* Render the simplified dropdown input lists (enabled inputs only)
*/
// =============================================================================
// Frame-rate report
// =============================================================================

/**
* Per-label frame-rate statistics, accumulated in memory.
*
* Exists because the wall reported lag that no measurement on a dev machine
* reproduces, and nothing told us the frame rate the wall was actually achieving.
* The GPU report established the hardware is healthy and the renderer is on D3D11;
* this answers the next question, which is what it manages at 6000x1200.
*
* **Bounded by the number of savers, not by uptime.** One row per label ever seen
* -- 30 savers plus two boards is the ceiling -- each holding six numbers. The file
* is overwritten, never appended, same as the GPU report. A wall running for months
* accumulates a few KB, once.
*/
const FPS_SAMPLE_MS = 15_000
const FPS_REPORT_MS = 60_000

// Ignore a sample interval shorter than this: a saver that started or stopped
// mid-interval drew for only part of it, and dividing by the full interval would
// invent a low frame rate that never happened.
const FPS_MIN_SAMPLE_SECONDS = 3

/** @type {Map<string, {samples:number,frames:number,seconds:number,min:number,max:number,last:number,size:string}>} */
const fpsStats = new Map()

/**
* Fold one round of frame counters into the running statistics.
*
* Exported for tests: this is the arithmetic worth pinning, and it needs no GL.
*/
function accumulateFrameStats (samples, stats = fpsStats) {
for (const s of samples) {
if (s.seconds < FPS_MIN_SAMPLE_SECONDS) continue
const fps = s.frames / s.seconds
const prev = stats.get(s.label)
if (!prev) {
stats.set(s.label, {
samples: 1,
frames: s.frames,
seconds: s.seconds,
min: fps,
max: fps,
last: fps,
size: `${s.width}x${s.height}`,
})
continue
}
prev.samples++
prev.frames += s.frames
prev.seconds += s.seconds
prev.min = Math.min(prev.min, fps)
prev.max = Math.max(prev.max, fps)
prev.last = fps
prev.size = `${s.width}x${s.height}`
}
return stats
}

/**
* The report body: one line per label, worst mean first.
*
* Sorted by mean rather than by name because the question being asked is always
* "what is slowest", and on a 30-row table alphabetical order buries the answer.
*
* Exported for tests.
*/
function formatFpsReport (stats = fpsStats) {
const rows = [...stats.entries()]
.map(([label, v]) => ({ label, mean: v.frames / v.seconds, ...v }))
.sort((a, b) => a.mean - b.mean)

const lines = []
lines.push('Overwritten on every write; nothing here is appended.')
lines.push('')
if (rows.length === 0) {
lines.push('No frames counted yet.')
return lines.join('\n')
}
lines.push(
'saver'.padEnd(26) + 'mean'.padStart(7) + 'min'.padStart(7) +
'max'.padStart(7) + 'last'.padStart(7) + ' n'.padStart(4) + ' size')
for (const r of rows) {
lines.push(
r.label.slice(0, 25).padEnd(26) +
r.mean.toFixed(1).padStart(7) +
r.min.toFixed(1).padStart(7) +
r.max.toFixed(1).padStart(7) +
r.last.toFixed(1).padStart(7) +
String(r.samples).padStart(4) + ' ' + r.size)
}
return lines.join('\n')
}

// Only the sample timer is held, as the re-entry guard. Both intervals run for the
// app's lifetime -- there is no state in which the wall wants to stop knowing its
// frame rate -- so a handle for the second one would be state nobody reads.
let fpsSampleTimer = null

/**
* Start sampling. Two timers on purpose: counters are read often enough that a
* short-lived saver is not missed, and written to disk rarely, because the file is
* the part with a cost.
*/
function startFpsInstrumentation () {
if (fpsSampleTimer !== null) return
fpsSampleTimer = setInterval(() => {
accumulateFrameStats(sampleFrameCounters())
}, FPS_SAMPLE_MS)
setInterval(() => {
if (fpsStats.size === 0) return
window.electronAPI?.writeFpsReport?.(formatFpsReport())
.catch(err => console.error('[FPS] report failed:', err))
}, FPS_REPORT_MS)
}

// =============================================================================
// GPU report
// =============================================================================
Expand Down Expand Up @@ -3499,6 +3620,10 @@ async function init() {
// are the ones this session will actually run with.
reportGpu()

// Frame-rate sampling. Cheap enough to leave on: one property increment per
// frame, a counter read every 15s, and one overwritten file every 60s.
startFpsInstrumentation()

// Show cursor initially
showCursor()

Expand Down Expand Up @@ -3547,6 +3672,8 @@ export {
hideNoSignal,
formatDowntime,
boardRowsFor,
accumulateFrameStats,
formatFpsReport,
refreshNoSignalBoards,
applyForcedNoSignal,
updateDvdScreensaver,
Expand Down
78 changes: 77 additions & 1 deletion input_viewer_electron/src/renderer/screensavers/gl-base.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,62 @@ const frameObservers = []
* @param {(rgba: Uint8Array, pixelCount: number) => void} fn
* @returns {() => void} unsubscribe
*/
/**
* Live frame counters, one per running runtime.
*
* The wall reported lag that none of the measurements on a dev machine reproduce,
* and there was no way to see the frame rate the wall was actually achieving.
* This is the cheapest possible instrument: one property increment per frame in a
* loop that already increments a counter, and nothing else on the hot path. No
* readback, no allocation, no timing call.
*
* Keyed by label so several concurrent runtimes stay distinguishable -- in dual
* view with no signal there are two split-flap boards AND possibly a screensaver,
* each with its own runtime, and an aggregate figure would hide which one is slow.
*/
const liveRuntimes = new Set()

/**
* Label the next runtime that gets created.
*
* The 30 savers all call createGLRuntime(canvas) from inside their own create(),
* so the name lives one level up -- in the registry, which knows which entry it is
* instantiating. Rather than thread a label through 30 files, whoever is about to
* instantiate announces it here.
*
* Consumed on use, so a label cannot leak onto an unrelated runtime built later.
*/
let pendingRuntimeLabel = null
export function setNextRuntimeLabel(label) {
pendingRuntimeLabel = label || null
}

/**
* Read and reset every live runtime's frame count.
*
* Returns one entry per runtime with the frames drawn and the wall-clock interval
* they were drawn over, leaving the caller to compute a rate -- so a caller that
* samples irregularly still gets an honest number.
*
* @returns {Array<{label: string, frames: number, seconds: number, width: number, height: number}>}
*/
export function sampleFrameCounters(now = performance.now()) {
const out = []
for (const rec of liveRuntimes) {
const seconds = (now - rec.since) / 1000
out.push({
label: rec.label,
frames: rec.frames,
seconds,
width: rec.canvas.width,
height: rec.canvas.height,
})
rec.frames = 0
rec.since = now
}
return out
}

export function observeFrames(fn) {
frameObservers.push(fn)
return () => {
Expand All @@ -204,7 +260,7 @@ export function observeFrames(fn) {
* @param {HTMLCanvasElement} canvas
* @returns {object} runtime
*/
export function createGLRuntime(canvas) {
export function createGLRuntime(canvas, options = {}) {
const gl = canvas.getContext('webgl2', {
antialias: true,
alpha: false,
Expand All @@ -214,6 +270,16 @@ export function createGLRuntime(canvas) {
throw new Error('WebGL2 is not available')
}

// Frame counter for this runtime. `label` is what shows up in the fps report;
// anonymous runtimes are still counted so the total is never misleading.
const counter = {
label: options.label || pendingRuntimeLabel || 'unlabelled',
frames: 0,
since: 0,
canvas,
}
pendingRuntimeLabel = null

// Fullscreen quad (two triangles covering clip space).
const quad = new Float32Array([-1, -1, 3, -1, -1, 3])
const vao = gl.createVertexArray()
Expand Down Expand Up @@ -384,6 +450,11 @@ export function createGLRuntime(canvas) {
lastTime = 0
dt = FALLBACK_DT
resize()
// Registered on start rather than on create, so a runtime that is built and
// never started does not appear as a 0 fps entry.
counter.frames = 0
counter.since = performance.now()
liveRuntimes.add(counter)
const loop = () => {
resize()
const time = (performance.now() - startTime) / 1000
Expand All @@ -403,6 +474,8 @@ export function createGLRuntime(canvas) {
// No observer means one branch per frame and nothing else.
if (frameObservers.length) notifyFrameObservers()
frame++
// The whole instrument: one increment. See liveRuntimes.
counter.frames++
rafId = requestAnimationFrame(loop)
}
rafId = requestAnimationFrame(loop)
Expand All @@ -414,6 +487,9 @@ export function createGLRuntime(canvas) {
rafId = null
}
onFrame = null
// Dropped on stop, so a rotated-away saver stops reporting rather than
// lingering at whatever rate it last managed.
liveRuntimes.delete(counter)
}

function destroy() {
Expand Down
4 changes: 4 additions & 0 deletions input_viewer_electron/src/renderer/screensavers/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
* SCREENSAVERS array below. Pure fragment-shader ones can use
* createShaderScreensaver from gl-base.js.
*/
import { setNextRuntimeLabel } from './gl-base.js'
import { seedFromClock } from './seed.js'
import dvdLogo from './dvd-logo.js'
import plasma from './plasma.js'
Expand Down Expand Up @@ -220,6 +221,8 @@ export function startScreensaver(selector, seed) {
// value can be logged -- reproducing a look means knowing what to pass back.
const resolvedSeed = seed === undefined || seed === null ? seedFromClock() : seed
try {
// Name the runtime the saver is about to build, for the fps report.
setNextRuntimeLabel(saver.name)
active = saver.create(canvasEl, resolvedSeed)
active.start()
running = true
Expand All @@ -236,6 +239,7 @@ export function startScreensaver(selector, seed) {
if (saver !== SCREENSAVERS[0]) {
activeIndex = 0
lastIndex = 0
setNextRuntimeLabel(SCREENSAVERS[0].name)
active = SCREENSAVERS[0].create(canvasEl, resolvedSeed)
active.start()
running = true
Expand Down
Loading